Qook: Перенести стару іграшку на Android і поділитися нею зі світом

КДПВ

Насправді, я дуже люблю логічні іграшки. Не, «три в ряд», «знайди схожий» та інші «погодуй собачку» мене мало цікавлять. А ось по-справжньому складна штуковина може спокійно тягнути на пару тижнів. Приблизно так і сталося зі мною в 2004-му році, коли до мене в руки потрапив новенький мобільний телефон від Sony. Здатність цього T68I відмінно дзвонити, показувати кольорові картинки і, за чутками, навіть надсилати свої контакти з BT пройшли повз мене непоміченими. А ось Q – ні. І скільки годин я просидів за маленьким дисплеєм, судорожно ганяючи кульки туди-сюди я вже і не пам'ятаю. Натомість, чудово пам'ятаю, що, ідея написати порт цієї гри для якої-небудь із сучасних платформ мене не відпускала з часів свого першого Hello World. Правда, всі мої спроби склепати хоч якийсь ігровий движок в ті старі-добрі часи розбивалися об… взагалі про що-то вони розбивалися. Зате тепер я давно і міцно пишу на Java, а з деяких (зовсім недавніх) пір ще і для Android, так що ідея порту іграшки нарешті знайшла можливість бути реалізованою. Хочете подивитися, що воно є і як воно вийшло? Тоді – під кат.

А в чому сенс цієї гри?

Q – дуже складна логічна гра, сенс якої в тому, щоб закотити всі кольорові кулі на ігровому полі в лунки того ж кольору. Майже як у биллиарде, ага. І зовсім як у биллиарде, кульки рухаються тільки по прямій лінії і тільки до першого перешкоди, яким може бути або нерухомий цегла, або інший кулька. При цьому всі рівні побудовані так, щоб виключити найпростіші рішення – в цьому-то і краса.

Все одно не зрозуміли? Гаразд, ось вам картинка першого рівня. Придумайте, до речі, на дозвіллі як цей самий рівень пройти.



Так краще? Тоді давайте ще зрозуміємо, як би це написати

На що пишемо?

Першим бажанням, яке прийшло мені в голову, було використовувати який-небудь фізичний движок. Ну, Unity, наприклад. Правда, після того як я подивився, скільки важать такі іграшки і скільки вони жеруть батарейки – ідея використовувати цілий движок тільки для того, щоб красиво катати кульки по полю померла негайно. Зате з'явилася ідея написати свій власний маленький движок спеціально для цієї гри, тим більше, що саме ця частина у мене і не виходила в дитинстві. Так що будемо винаходити свій велосипед: з кульками і низьким енергоспоживанням. До речі, винаходити його ми будемо на Java, Android. Поїхали?

Виділяємо ігрові елементи

Це перше, що потрібно зробити при написанні коду чого б то не було. Давайте подивимося, що у нас є… так, подивимося на картинку ще раз…

Ага! На полі у нас є… елементи! Логічно?

public abstract class Item implements Serializable {
private Color color;

public Item(Color color) {
setColor(color);
}

public Color getColor() {
return color;
}

private void setColor(Color color) {
this.color = color;
}

@Override
public String toString() {
return "Item{" +
"color=" + color +
'}';
}
}

Що, де координати? А звідки кулька знає про те, де він там знаходиться? Не його справа.

Тепер спустимося глибше і подивимося які саме елементи у нас тут є

Блок. З ним все просто: він квадратний, сірий і нікуди не рухається. Ні дати, ні взяти – економіка якої-небудь не сильно розвиненої країни. Правда, треба не забути, що блок – це елемент.

public class Block extends Item {
public Block() {
super(Color.GRAY);
}
}

Кулька. З кулькою трохи складніше: він круглий, різнобарвний і весь час кудись катається. І теж елемент.

public class Ball extends Item {
public Ball(Color color) {
super(color);
}
}

Дірка. Ну, або луза – як вам більше подобається. Вона у нас щось середнє між кулькою і блоком: начебто квадратна і нерухома, але теж різнобарвна.

public class Hole extends Item {
public Hole(Color color) {
super(color);
}
}

Так, з базовими елементами ми вже розібралися. Тепер подумаємо про те, де вони всі лежать

Пишемо рівень

З самими рівнями ми будемо розбиратися трохи пізніше, а поки нам потрібен клас, який буде відповідати за розташування елементів всередині поля, раз вже ми з вами вирішили, що самі вони про себе нічого, крім кольору і назви не знають.

public class Level implements Serializable {
private Item[][] field;
private int ballsCount;

public Level(Item[][] field) {
this.field = field;
}
}

Ну ось, початок хороший. У нас є якийсь рівень, який зберігає в собі якийсь двовимірний масив з елементів. Оскільки всі кульки-блоки-дірки у нас і є елементи, так можна. Друга змінна нам потрібна для того, щоб не підраховувати кількість залишилися на полі кульок кожен раз. Втім, один раз це справа порахувати по-чесному таки доведеться

private int countBallsOnLevel(Item[][] field) {
int ballsCount = 0;

for (Item[] aField : field) {
for (int j = 0; j < field[0].length; j++) {
if (aField[j] != null && aField[j].getClass().equals(Ball.class)) {
ballsCount++;
}
}
}

return ballsCount;
}

Квадратична складність, ага. Саме тому я і не хочу перераховувати це значення після чергового ходу. Ну і додамо одну сходинку в конструктор

this.ballsCount = countBallsOnLevel(field);

Так, рівень у нас готовий. Тепер за планом найцікавіше

Пишемо движок

Нехай всій ігровий механікою у нас займається окремий спеціальний клас. Ну, наприклад, Field, який буде зберігати в собі змінну конфігурацію рівня, а також кількість залишилися на полі кульок

private Level level;
private int ballsCount;

public Field(Level level) {
this.level = level;
this.ballsCount = level.getBallsCount();
}

Відмінно. Тепер ненадовго відвернемося від движка і напишемо невеликий enum

public enum Direction {
LEFT,
RIGHT,
UP,
DOWN,
NOWHERE
}

Ага, напрямок переміщення кульки. Тепер відвернемося ще раз і напишемо зовсім маленький класик, який буде зберігати в собі координати потрібного елемента на полі. Навіщо? А щоб потім писати менше

public class Coordinates {
private int horizontal;
private int vertical;

public Coordinates(int horizontal, int vertical) {
this.horizontal = horizontal;
this.vertical = vertical;
}
}

Ура, нарешті-то можна повернутися назад до движка і продовжити наш непосильна праця.

Перше, що хочеться зробити – так це навчити наше поле переміщати кульки.

private Coordinates moveRight(int xCoord, int yCoord) {
try {
while (level.getField()[yCoord][xCoord + 1] == null) {
level.getField()[yCoord][xCoord + 1] = level.getField()[yCoord][xCoord];
level.getField()[yCoord][xCoord++] = null;
}
} catch (ArrayIndexOutOfBoundsException ex) {
}

return new Coordinates(xCoord, yCoord);
}

Ось цей метод, наприклад, буде котити кульку до тих пір, поки не зустрінеться яка-небудь пристойне перешкоду. Ну, або поки поле не закінчиться. До речі, це, мабуть, єдиний випадок, коли придушення винятків взагалі хоч якось виправдано.

Не складніше, ніж цей – пишуться й інші методи для переміщення кульки вліво, вгору і вниз. Треба тільки навчитися викликати ці методи де-небудь рівнем вище.

private Coordinates moveItem(Coordinates coordinates, Direction direction) {

int horizontal = coordinates.getHorizontal();
int vertical = coordinates.getVertical();

if (direction.equals(Direction.NOWHERE) || level.getField()[vertical][horizontal] == null) {
return null;
}

Class clazz = level.getField()[vertical][horizontal].getClass();
if (!clazz.equals(Ball.class)) {
return null;
}

switch (direction) {
case RIGHT:
return moveRight(horizontal, vertical);

case LEFT:
return moveLeft(horizontal, vertical);

case UP:
return moveUp(horizontal, vertical);

case DOWN:
return moveDown(horizontal, vertical);
}

return null;
}

Ну ось і наші координати знадобилися. Я ж казав, що так менше писати.

Так, кататися більш-менш навчилися. Тепер будемо вчитися заходило. Все те ж саме, тільки метод у нас буде ще й повертати результат операції – вийшло з'їсти кульку чи ні

private boolean acceptRight(Coordinates coordinates) {
try {
int horizontal = coordinates.getHorizontal();
int vertical = coordinates.getVertical();

Item upItem = level.getField()[vertical][horizontal + 1];
Item item = level.getField()[vertical][horizontal];

if (upItem == null || !upItem.getClass().equals(Hole.class) || !(upItem.getColor().equals(item.getColor ()))) {
return false;
}

level.getField()[vertical][horizontal] = null;
} catch (ArrayIndexOutOfBoundsException ex) {
}

return true;
}

І точно така ж обгортка рівнем вище

private boolean acceptHole(Coordinates coordinates, Direction direction) {
boolean isAccepted = false;
switch (direction) {
case UP:
isAccepted = acceptUp(coordinates);
break;

case DOWN:
isAccepted = acceptDown(coordinates);
break;

case RIGHT:
isAccepted = acceptRight(coordinates);
break;

case LEFT:
isAccepted = acceptLeft(coordinates);
break;
}

if (!isAccepted) {
return false;
}

catchBall();

return checkWin();

}

Після того, як кулька вийшло з'їсти, треба ще перерахувати кількість залишилися. Ні, там не O(N).

private void catchBall() {
ballsCount--;
}

Чому? Тому що за один хід ми можемо переміщати тільки одна кулька, а значить – і закотити більше у нас не вийде. Перевірка на те, що рівень закінчено робиться не складніше

private boolean checkWin() {
return ballsCount == 0;
}

Ну от, тепер ми можемо катати і закочувати кульки по полю. Залишилося навчитися ходити

public boolean makeTurn(Coordinates coordinates, Direction direction) {
Coordinates newCoordinates = moveItem(coordinates, direction);
return newCoordinates != null && acceptHole(newCoordinates, direction);
}

Нічого нового: взяли координати з напрямком, якщо вийшло, перемістили кульку на нове місце і загнали його в дірку, якщо вона там знайшлася. Якщо знайшлася – повернули true.

Ну ось і весь движок. І варто було через це чіпляти сюди якийсь unity?

Тепер треба тільки навчити телефон все це справа показувати на екрані.

Пишемо свою в'юшку

Основний елемент інтерфейсу програми для Android – View. В'юшка, тобто. Це і кнопочка, і поле для введення і… наше ігрове поле. Правда, дивно сподіватися, що за нас його вже хтось написав. Так що доведеться зробити це самим. Для цього ми створимо цілий клас і отнаследуем його від вбудованого View андройда, щоб отримати доступ до його життєвого циклу, можливості розміщувати цю справу на екрані і ще багато чого

public class FieldView extends View {
private final double ROUND_RECT_SIZE = 0.15;
private final int PADDING_DIVIDER = 4;
int paddingSize = 0;
private int elementSize;
private Field field;
private Size fieldSize;
private Size maxViewSize;

public FieldView(Context context, AttributeSet attrs) {
super(context, attrs);
}
}


Навіщо тут потрібні константи, ми розберемося потім, а поки подумаємо про те, якого розміру повинна бути змія. Зрозуміло, що вона повинна займати якомога більше місця на екрані, але не виходити за його межі. І зрозуміло, що розмір елементів повинен бути пропорційний розміру самої в'юхи. При цьому поставити щось константним ми не можемо – не писати ж свою змію під пару тисяч різних телефонів. Зате ми можемо що-небудь зробити з в'юшкою при розміщенні її на екрані. Оскільки в XML-розмітці вона у нас буде мати розмірності math_parent, то цей самий розмір ми зможемо визначити runtime.

public Size countFieldSize() {
if (maxViewSize == null) {
maxViewSize = new Size(this.getWidth(), this.getHeight());
}

int horizontalElementsNum = field.getField()[0].length;
int verticalElementsNum = field.getField().length;

int maxHorizontalElSize = maxViewSize.getWidth() / horizontalElementsNum;
int maxVerticalElSize = maxViewSize.getHeight() / verticalElementsNum;

this.elementSize = (maxHorizontalElSize < maxVerticalElSize) ? maxHorizontalElSize : maxVerticalElSize;

int newWidth = this.elementSize * horizontalElementsNum;
int newHeight = this.elementSize * verticalElementsNum;

return new Size(newWidth, newHeight);
}

Size у нас це приблизно те ж, що і координати, тільки потрібен для зберігання розмірів за Ox і Oy. Алгоритм простий: подивилися, не визначив ці розміри хто-небудь до нас, отримали висоту і ширину в пікселях, прикинули скільки буде займати один елемент по горизонталі і по вертикалі, вибрали менший, так і перерахували розмір самої в'юхи домножив розмір елемента на їх кількість по рядку і стовпцю.

А, ну і не забути викликати цю справу:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
Size countedFieldSize = countFieldSize();
if (fieldSize == null || !fieldSize.equals(countedFieldSize)) {
this.fieldSize = countedFieldSize;
setFieldSize(this.fieldSize);
paddingSize = (int) (Math.sqrt(elementSize) / PADDING_DIVIDER);
}

}

Що робить setFieldSize? Та будь ласка!

public void setFieldSize(Size size) {
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(size.getWidth(), size.getHeight());
params.gravity = Gravity.CENTER_HORIZONTAL;
this.setLayoutParams(params);
}

Взяли в'юшку, так і причепили до неї розміри. А що ви хотіли?

Так, ми визначилися з розмірами. Тепер нам треба якось промалювати ігрове поле. Це не складно і робиться в onDraw. Правда, перш ніж щось малювати треба б десь знайти самі ігрові елементи.

Малюємо

Перше, що мені прийшло в голову – завести цілу купу файлів розмітки в drawable і підсовувати їх на canvas по координатах. На превеликий жаль, ця геніальна ідея зламалася про неможливість ставити відносні розміри елементів. Тобто, я можу зробити у блоку округлені кути і поставити їх у dp. І вони насправді будуть округленими. Проблема тільки в тому, що розмір елемента в нас змінюється в залежності від кількості цих самих елементів на полі. І якщо поле у нас 6*6 (мінімальний розмір у грі), блоки будуть квадратними зі злегка закругленими кутами. А якщо поле у нас аж 13*13 (максимальний розмір) – це будуть злегка квадратні кульки. Негарно.
Проте, сама ідея малювати на canvas готовими елементами мені подобається більше, ніж морочитися з якоюсь низькорівневої малюванням, начебто drawRect. Давайте наробимо купу елементів?

Генерацією Drawable у нас буде займатися окремий метод (хоча, мені чомусь хотілося винести це в окрему фабрику) selectDrawable, який приймає екземпляр, з'ясовує хто він такий і що робить для нього drawable. Наприклад, блок буде малюватися приблизно так:

Class clazz = item.getClass();
Color color = item.getColor();

if (clazz.equals(Block.class)) {
GradientDrawable bgShape = new GradientDrawable();
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.gray));
bgShape.setCornerRadius((float) (elementSize * ROUND_RECT_SIZE));
return bgShape;
}

Ну ось і константи знадобилися. Тепер радіус скруглення у нас залежить від розміру самого елемента. Якраз те, чого ми й домагалися.

Тепер подивимося на те, як будується drawable для шарика, який у нас різнобарвний:

if (clazz.equals(Ball.class)) {
GradientDrawable bgShape = new GradientDrawable();
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.gray));
bgShape.setCornerRadius(elementSize);

switch (color) {
case GREEN:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.green));
return bgShape;

case RED:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.red));
return bgShape;

case BLUE:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.blue));
return bgShape;

case YELLOW:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.yellow));
return bgShape;

case PURPLE:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.purple));
return bgShape;

case CYAN:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.cyan));
return bgShape;

}
}

Так набагато складніше. Спочатку намалювали кульку, а потім полили його потрібної фарбою. Навіщо тут switch і чому не можна просто задати колір тим, що ми дістали з кульки?
Тому що це різні кольори. Колір, який зберігається в елементі, це звичайний enum, який з Java, а той, що приймає drawable в якості кольору – нормальний android-ресурс з нормальним строковим значенням. Наприклад, ось вам червоненький:

<color name="red">#D81B60</color>

Склеювати одне з іншим погана ідея, тому що коли-небудь мені прийде в голову, що червоний – недостатньо синій і взагалі пора погратися зі шрифтами і доведеться все це справа переписувати замість того, щоб просто виправити ресурсний файл.

Ну і на закуску – будуємо drawable з дірки:

if (clazz.equals(Hole.class)) {
GradientDrawable bgShape = new GradientDrawable();
bgShape.setCornerRadius((float) (elementSize * ROUND_RECT_SIZE));

switch (color) {
case GREEN:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.green));
return bgShape;

case RED:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.red));
return bgShape;

case BLUE:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.blue));
return bgShape;

case YELLOW:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.yellow));
return bgShape;

case PURPLE:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.purple));
return bgShape;

case CYAN:
bgShape.setColor(ContextCompat.getColor(getContext(), R. color.cyan));
return bgShape;
}
}

Знову нічого нового: намалювали дірку, пофарбували і віддали її прохачеві

Так, нічого не забули? Хм… Дірки, кульки, блоки… А пусте місце? Що, наприклад, буде, якщо в масиві зустрівся null?

if (item == null) {
GradientDrawable bgShape = new GradientDrawable();
bgShape.setColor(ContextCompat.getColor(getContext(), android.R.color.transparent));
bgShape.setCornerRadius((float) (elementSize * ROUND_RECT_SIZE));
return bgShape;
}

Та нічого нового не буде, тому що це точно такий же красивий округлений квадратик. Шкода, тільки що невидимий.

Готово, елементи ми вміємо будувати. На чому ми там зупинилися? А… так! На те, щоб їх промалювати

@Override
protected void onDraw(Canvas canvas) {
if (field == null) {
return;
}

for (int i = 0; i < field.getField().length; i++) {
for (int j = 0; j < field.getField()[0].length; j++) {
Drawable d = selectDrawable(field.getField()[i][j]);
d.setBounds(j * elementSize + paddingSize, i * elementSize + paddingSize, (j + 1) * elementSize - paddingSize, (i + 1) * elementSize - paddingSize);
d.draw(canvas);
}
}
}


Ну ось. Пройшлися по всьому полю, для кожного елемента знайдемо його графічне подання, встановили йому розміри і відступи один від одного і відмалювали його на полотні. До речі, цікаво, що тут саме drawable малює на полотні, а не полотно малює на собі drawable. Для того, щоб це зробити, довелося б щоразу конвертить drawable в bitmap, а це довго.

Давайте подивимося на те, що вийшло? Для цього напишемо якийсь тестовий рівень, де елементи задані прямо в конструкторі (приберемо-приберемо, не переживайте)

А ось так писати краще не треба
public class Level {
private Item[][] field;

public Item[][] getField() {
return field;
}

public Level() {
field = new Item[6][6];
field[0][0] = new Block();
field[0][1] = new Block();
field[0][2] = new Hole(Color.RED);
field[0][3] = new Block();
field[0][4] = new Block();
field[0][5] = new Block();

field[1][0] = new Block();
field[1][1] = new Ball(Color.RED);
field[1][2] = new Ball(Color.GREEN);
field[1][3] = new Ball(Color.YELLOW);
field[1][4] = new Ball(Color.CYAN);
field[1][5] = new Block();

field[2][0] = new Block();
field[2][1] = new Hole(Color.GREEN);
field[2][2] = new Hole(Color.YELLOW);
field[2][3] = new Hole(Color.PURPLE);
field[2][4] = new Hole(Color.CYAN);
field[2][5] = new Hole(Color.BLUE);

field[3][0] = new Block();
field[3][1] = new Ball(Color.PURPLE);
field[3][5] = new Block();

field[4][0] = new Block();
field[4][1] = new Block();
field[4][3] = new Ball(Color.BLUE);
field[4][5] = new Block();

field[5][1] = new Block();
field[5][2] = new Block();
field[5][3] = new Block();
field[5][4] = new Block();
}
}


А тепер причепимо нашу в'юшку до якого-небудь актівіті і запустимо цю справу



Нарешті-то воно показує!

А тепер натхненні такою красивою картинкою навчимо нашу в'юшку інтерактивності

Ганяємо кулі

Оскільки движок, який уміє переміщати елементи, у нас вже є, нам залишається тільки знайти спосіб викликати відповідні методи як-то взаємодіючи з вьюхой.

Взаємодіяти з ігровим полем можна по-різному. Якщо користувачів зовсім не шкода, можна навіть зробити управління таким же, як в оригінальній грі – приробити віртуальний джойстик і натискати на нього до посиніння. А можна згадати про те, що нативний жест для сенсорного екрану, це все-таки свайп і змахувати кулі в потрібну сторону. Зрозуміли, що ми зібралися зробити? Тоді поїхали

Взагалі, для Android є вбудований GestureManager, але чи то я так і не зрозумів, як ним користуватися, то на моєму тестовому девайсі він працює як попало, але запустити його так, щоб не було помилок розпізнавання ніде моїми кривими ручками чомусь не вийшло. Так що зараз візьмемо і напишемо свій власний

Отже, рухатися наші з вами кульки можуть рівному в чотирьох напрямах: вгору, вниз, вліво і вправо. Щоправда, крім цього, вони можуть взагалі нікуди не рухатися, але це зовсім не цікаво. Так що для того, щоб визначати напрямок руху кульки, розпізнавати нам потрібно всього 4 простих жесту.

Не особливо заморочуючись, починаємо писати ще один метод:

public Direction getSwipeDirection(float downHorizontal, float upHorizontal, float downVertical, float upVertical) {
float xDistance = Math.abs(upHorizontal - downHorizontal);
float yDistance = Math.abs(upVertical - downVertical);
double swipeLength = getSwipeLength(xDistance, yDistance);

if (swipeLength < elementSize / 2) {
return Direction.NOWHERE;
}

if (xDistance >= yDistance) {
if (upHorizontal > downHorizontal) {
return Direction.RIGHT;
}
return Direction.LEFT;
}

if (yDistance > xDistance) {
if (upVertical > downVertical) {
return Direction.DOWN;
}
return Direction.UP;
}

return Direction.DOWN;
}

Direction – це Enum, який ми описали вище, а все інше зовсім просто: отримали 4 координати (звідки ми їх отримали поки не важливо) і порахували відстань по вертикалі і горизонталі. Потім згадали курс геометрії середньої школи і знайшли довжину самого свайпа. Якщо вона зовсім маленька, подумаємо, що юзер тут ні при чому і нічого робити не будемо. Якщо ж свайп був хороший, визначимо куди він такий хороший був і повернемо користувачеві напрямок. Класно? Мені теж подобається.

Ну, припустимо, напрямок свайпа ми визначати з горем навпіл навчилися. А який з кульок ми, вибачте, свайпнули? Давайте розбиратися.

Так, у нас є координати точки дотику (ще у нас є координати точки відриву, але що ми будемо з ними робити?) і за цим координатам нам потрібно знайти елемент… Хм.

public Coordinates getElementCoordinates(float horizontal, float vertical) {
float xElCoordinate = horizontal / elementSize;
float yElCoordinate = vertical / elementSize;

return new Coordinates((int) xElCoordinate, (int) yElCoordinate);
}

Нічого незвичайного. Якщо всі елементи одного і того ж розміру, розмір якого ми знаємо (самі визначали), а розмір поля ми і без того вже порахували – залишається тільки взяти і поділити. А працювати з елементом за його координатами – завдання движка

Тепер ми точно знаємо, що ми свайпнули і навіть здогадуємося куди. Залишається тільки передати все це справа движку і нехай собі торохтить. От тільки це вже не завдання в'юшки. Її справа – показувати, а, обробляти будь-які дії треба б або у фрагментах, або в актівіті. З фрагментами у нас негусто, а ось яке-ніяке актівіті є. Повісимо на в'юшку onTouchLictener.

private OnTouchListener onFieldTouchListener = new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downHorizontal = event.getX();
downVertical = event.getY();
break;

case MotionEvent.ACTION_UP:
upHorizontal = event.getX();
upVertical = event.getY();

boolean isWin = fieldView.getField().makeTurn(
fieldView.getElementCoordinates(downHorizontal, downVertical),
fieldView.getSwipeDirection(downHorizontal, upHorizontal, downVertical, upVertical)
);
}

Ну ось. При торканні будемо зберігати координати, при відпусканні дисплея будемо отримувати ще пару координат, а потім — збирати в купу і передавати в'юшки – хай сама розбирається. В'юшка віддасть все це справа далі, дістане булевий результат, який ні багато ні мало, а ознака завершення рівня і поверне нам. Залишається лише обробити ну і не забути сказати в'юшки, що треба перерисоваться.

Дописуємо в листенер:

fieldView.invalidate();

if (isWin) {
animateView(fieldView);

try {
levelManager.finishLevel();
openLevel(levelManager.getCurrentLevelNumber());

} catch (GameException ex) {
onMenuClick();
}
}
}
return true;

Перевірили і навіть щось зробили. Що саме ми зробили, зрозуміємо трохи пізніше, а поки давайте пограємося. Приберемо з нашого Level все зайве, залишимо тільки дві кульки і спробуємо загнати один з них в лузу



Хе-хе. Воно навіть працює. Тоді рухаємося далі

Шукаємо рівні

Ну ось. Робочий движок ми написали, це красиво малювати навчилися, тепер залишилося тільки визначитися з тим, що саме ми навчилися малювати. З рівнями, ага.

Спочатку, я не збирався малювати рівні самостійно, тому що наша з вами мета – портувати гру, а не написати нову. Стало бути, треба десь взяти оригінальну Q, гарненько її струсити, відчепити від неї трьох-чотирьох юристів Sony (ну, це все-таки її іграшка) і вуаля – готово.

Ось тільки пошуки оригінальної іграшки нічим не закінчилися. Того самого T68 давно вже немає, картинок в мережі чомусь не виявилося, а знайти ще один оригінальний девайс в 2016-му році… проблематично знайти, в загальному. Трагедія.

Правда, поки я шукав оригінал, я зовсім випадково натрапив на порт цієї іграшки під Windows 98. І яким же великим було моє здивування, коли я зрозумів, що рівні до цієї іграшці не тільки здерті з оригіналу, але і лежать в звичайних txt файлах, де кожен символ позначає або блок, або кулька, або дірку. Поспілкувавшись з автором і заручившись його згодою на використання, я зі спокійною совістю забрав їх собі і навіть спробував зрозуміти, що з ними робити.

Просто так, взяти і засунути txt-файли в іграшку звичайно можна, хто б був проти. Але змусити мій движок з ними працювати – треба ще постаратися. Треба так треба. Постараємося

Пишемо Level Manager

Звучить красиво, на практиці – ще один клас, який буде робити з txt-файлів нормальні об'єкти рівнів і віддавати їх кому скажуть. А заодно буде розбиратися з тим, який рівень у нас поточний, скільки їх є і іншими принадами життя.

Оскільки менеджер у нас береже в собі якийсь стан (наприклад, номер поточного рівня), буде неприємно, якщо в якийсь момент, ми здуру зробимо собі новий, в якому цього стану ще не буде (особливо неприємно буде старим менеджеру, якого система благополучно пристрелить). Так що зробимо його краще сінглтоном, від гріха подалі

public class LevelManager {
private static final String LEVELS_FOLDER = "levels";
private static final String LEVEL_FILE_EXTENSION = ".lev";

private static final int EMPTY_CELL = 0;
private static final int BLOCK_CELL = 1;
private static final int GREEN_BALL_CELL = 2;
private static final int RED_BALL_CELL = 3;
private static final int BLUE_BALL_CELL = 4;
private static final int YELLOW_BALL_CELL = 5;
private static final int PURPLE_BALL_CELL = 6;
private static final int CYAN_BALL_CELL = 7;

private static final int GREEN_HOLE_CELL = 22;
private static final int RED_HOLE_CELL = 33;
private static final int BLUE_HOLE_CELL = 44;
private static final int YELLOW_HOLE_CELL = 55;
private static final int PURPLE_HOLE_CELL = 66;
private static final int CYAN_HOLE_CELL = 77;

private static Context context;
private static SharedSettingsManager sharedSettingsManager;
private static LevelManager instance;

private LevelManager() {
}

public static LevelManager build(Context currentContext) {
context = currentContext;
sharedSettingsManager = SharedSettingsManager.build(currentContext);

if (instance == null) {
instance = new LevelManager();
}
return instance;
}

Що це за купа констант? Це елементи легенди. Насправді, сперте чесно взятий рівень виглядає приблизно так:



І кожна цифра щось та позначає. А щоб не лізти кожен раз в довідку ігри, поназаводим зрозумілих оці констант і будемо працювати тільки з ними. Про sharedSettingsManager, який тут навіщо-то є я вам розповім іншим разом, а поки давайте навчимо наш менеджер відкривати рівень і будувати з нього пристойний об'єкт

Для початку спробуємо дістати сам рівень з файлу і як-то його розпарсити. Для висмикування різних чисел і літер з потоку даних у нас є Scanner, так що його ми на файлик і натравим

private Scanner openLevel(int levelNumber) throws IOException {
AssetManager assetManager = context.getAssets();
InputStream inputStream = assetManager.open(
LEVELS_FOLDER +
"/" +
String.valueOf(levelNumber) +
LEVEL_FILE_EXTENSION);

BufferedReader bufferedReader =
new BufferedReader
(new InputStreamReader(inputStream));

return new Scanner(bufferedReader);
}

Так, трохи не забув. Всі рівні у нас зберігаються до assets андройда, дістати їх звідти не представляє ніякої праці, правда, тільки, якщо у нас є контекст. А називаються вони по своїх номерах. Тому, нам залишається тільки передати номер необхідного рівня, щоб отримати готовий сканер, який працює з потрібним файликом.

Тепер – будемо конвертувати елемент легенди в Item.

private Item convertLegendToItem(int itemLegend) {
switch (itemLegend) {
case EMPTY_CELL:
return null;

case BLOCK_CELL:
return new Block();

case GREEN_BALL_CELL:
return new Ball(Color.GREEN);

case RED_BALL_CELL:
return new Ball(Color.RED);

case BLUE_BALL_CELL:
return new Ball(Color.BLUE);

case YELLOW_BALL_CELL:
return new Ball(Color.YELLOW);

case PURPLE_BALL_CELL:
return new Ball(Color.PURPLE);

case CYAN_BALL_CELL:
return new Ball(Color.CYAN);

case GREEN_HOLE_CELL:
return new Hole(Color.GREEN);

case RED_HOLE_CELL:
return new Hole(Color.RED);

case BLUE_HOLE_CELL:
return new Hole(Color.BLUE);

case YELLOW_HOLE_CELL:
return new Hole(Color.YELLOW);

case PURPLE_HOLE_CELL:
return new Hole(Color.PURPLE);

case CYAN_HOLE_CELL:
return new Hole(Color.CYAN);
}

return null;
}

Один великий-превеликий switch і нічого складного.

Ну і нарешті навчимося обробляти весь рівень цілком:

public Level getLevel(int levelNumber) throws IOException {
Scanner scanner = openLevel(levelNumber);

int levelWidth = scanner.nextInt();
int levelHeight = scanner.nextInt();

Item levelMatrix[][] = new Item[levelHeight][levelWidth];

for (int i = 0; i < levelHeight; i++) {
for (int j = 0; j < levelWidth; j++) {
levelMatrix[i][j] = convertLegendToItem(scanner.nextInt());
}
}

Level level = new Level(levelMatrix);
sharedSettingsManager.setCurrentLevel(levelNumber);
return level;
}

Взяли номер – повернули рівень. Чудеса. Ось тільки крім відкриття рівня, треба ще й «завершувати» його в той момент, коли кульок там не залишилося. Визначати – залишилися там кульки чи ні це завдання движка, а ось обробляти раптово закінчився рівень будемо так

public void finishLevel() {
sharedSettingsManager.setCurrentLevel(
sharedSettingsManager.getCurrentLevel() + 1
);

if (sharedSettingsManager.getCurrentLevel() > sharedSettingsManager.getMaxLevel()) {
throw new GameException(GameExceptionCodes.INCORRECT_LEVEL);
}
}

Ага, зробили відмітку про те, що цей рівень пройдений, змінили номер поточного рівня і, якщо всі рівні скінчилися, плюнули виняток. Мило? Тоді спробуємо запустити нашу іграшку, наприклад, другому рівні



Хе! І правда, працює. Тоді саме час навчитися зберігати користувальницькі досягнення

Ще один менеджер

Якщо користувача кожен раз змушувати проходити всі рівні заново, користувач буде злий і невдоволений. Так що доведеться якось зберігати значення поточного і останнього доступного рівнів, щоб дані не губилися між запусками.

Для зберігання таких нескладних штуковин, Android є SharedSettings. Зазвичай, його використовують для зберігання налаштувань, але нам воно теж згодиться. Так що заведемо ще одного менеджера

public static final String LAST_LEVEL = "current_level";
public static final String MAX_LEVEL = "max_level";
public static final String WAS_RAN_BEFORE = "was_ran_before";
private static final String APP_PREFS = "qook_prefs";
public static Context context;
public static SharedSettingsManager instance;
SharedPreferences sharedPreferences;

private SharedSettingsManager() {
sharedPreferences = context.getSharedPreferences(APP_PREFS, Context.MODE_PRIVATE);
}

Теж сінглтон, ага. Тому що мало, де ми там вляпатися, а з'ясовувати – threadsafe самі sharedsettings особливого бажання чомусь немає.

Тепер, навчимося видавати поточний і максимальний рівень.

Раз

public int getMaxLevel() {
return sharedPreferences.getInt(MAX_LEVEL, 1);
}

І два

public int getCurrentLevel() {
return sharedPreferences.getInt(LAST_LEVEL, 1);
}

Тепер, спробуємо їх записувати назад. Викликати десь вище два окремих методу особливого бажання немає, тому зробимо ось як

private void setMaxLevel(int maxLevel) {
SharedPreferences.Редактор editor = sharedPreferences.edit();
editor.putInt(MAX_LEVEL, maxLevel);
editor.apply();
}


public void setCurrentLevel(int currentLevel) {
SharedPreferences.Редактор editor = sharedPreferences.edit();
editor.putInt(LAST_LEVEL, currentLevel);

editor.apply();

if (getMaxLevel() < currentLevel) {
setMaxLevel(currentLevel);
}
}

Тепер, по завершенні чергового рівня досить просто змінити поточний, а він останнім чи ні – менеджер розбереться і без нас. Інакше навіщо він тут взагалі сидить?

Дописуємо розмітку



Іграшка у нас вже грається, це добре. Нічого крім цього в ній немає – погано. А адже так хочеться і на кнопочку «спочатку» натиснути і в менюшку вийти і на якому ми рівні подивитися. Так що додамо над ігровим полем ось таку плашку



Код у кнопочок простіше не буває, так що розповідати, як воно працює не хочу. Тим більше, що, нічого крім дзвінків менеджерів або переходу в інше актівіті там немає

А ось як саму розмітку має сенс показати, там цікаво
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/game_activity"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context=".ui.activities.LevelActivity">


<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<TextView
android:id="@+id/level_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingTop="5dp"
android:text="01 / 60"
android:textSize="34sp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="right"
android:paddingBottom="10dp">

<ImageButton
android:id="@+id/back_level_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:src="@drawable/menu_icon" />

<ImageButton
android:id="@+id/reset_level_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:src="@drawable/restore_level" />

<ImageButton
android:id="@+id/undo_step_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:src="@drawable/undo_step" />

</LinearLayout>

</LinearLayout>

<org.grakovne.qook.ui.views.FieldView
android:id="@+id/field"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
android:background="@drawable/field_ground"
android:foregroundGravity="center" />

</LinearLayout>


Ну от самої іграшкою ми розібралися. Залишилося всього-то: дописати ще пару-трійку екранів, приробити вибір довільного рівня, додати ландшафтну розмітку і викласти цю справу стор. Дрібниці, сер!

Пишемо менюшку для рівнів

За цю справу буде відповідати окремий екран, який буде виглядати більше, ніж нудно: кожен рівень – квадратик, відкриті і ще не відкриті рівні різних кольорів, за ще не відкритим натискати не можна. Створимо разметочку?

<TextView
android:id="@+id/title_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:paddingBottom="16dp"
android:text="@string/app_name"
android:textAllCaps="true"
android:textSize="48sp"
android:textStyle="bold" />

<GridView
android:id="@+id/level_grid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="9"
android:numColumns="5">
</GridView>

Заголовочек і невелика гридвьюшка – нічого нового. Тепер, придумаємо як цю змію наповнити чимось корисним

Для цього нам доведеться написати адаптер, який буде створювати нову в'юшку, забивати її даними, навішувати на неї clickListener і запихати в батька. Приблизно так:

public View getView(int position, View convertView, ViewGroup parent) {
LayoutInflater vi;
vi = LayoutInflater.from(getContext());
@SuppressLint("ViewHolder") View view = vi.inflate(R. layout.level_item, null);

Integer currentLevelNumber = getItem(position);
if (currentLevelNumber != null) {
Button levelButton = (Button) view.findViewById(R. id.level_item_button);
if (levelButton != null) {

levelButton.setText(String.valueOf(currentLevelNumber));

if (position < maxOpenedLevel) {
levelButton.setBackgroundResource(R. drawable.opened_level_item);
levelButton.setClickable(true);
levelButton.setOnClickListener(clickListener);
levelButton.setId(currentLevelNumber);
} else {
levelButton.setBackgroundResource(R. drawable.closed_level_item);
levelButton.setClickable(false);
}
}
}

return view;
}

Здорово. Ми хочемо, щоб всі кнопочки рівнів були квадратними. Для цього створимо свого спадкоємця Button і додамо трохи магії:

public class LevelButton extends Button {
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
}

Хе-хе. Яка ширина, така і висота. Ніхто і не помітив. Залишилося тільки викликати це все при створенні актівіті

@Override
public void onResume() {
super.onResume();

manager = LevelManager.build(getBaseContext());

View.OnClickListener levelClick = new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(getBaseContext(), LevelActivity.class);
intent.putExtra(DESIRED_LEVEL, v.getId());
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
startActivity(intent);
}
};

LevelGridAdapter adapter = new LevelGridAdapter(this, R. layout.level_item, getListOfLevelNumbers(), manager.getMaximalLevelNumber(), levelClick);
adapter.setNotifyOnChange(false);
levelGrid.setAdapter(adapter);
levelGrid.setVerticalScrollBarEnabled(false);
}

Клікнули по кнопочці – рівень відкрився. Краса.

Граємося

Після того, як ми дописали до гри основне меню, допомогу і, звичайно ж, «про автора», саме час спробувати пограти в те, що у нас там вийшло.

Це у мене перші три рівня від зубів відскакують – натестился до коліків. Зате працює: і рівні зберігаються і переворот дисплея не зронив додаток, як ще три збірки тому, та й виглядає все це справа симпатично – прямо відчуваєш себе людиною.

Гаразд, раз це справа працює – викладемо це добро в Google Play, дивись, кому і сподобається

Реєструємося, платимо $25 гуглу, чекаємо трохи часу, створюємо проект, заповнюємо поля для даних і… отримуємо цілу сторінку в розділі «Головоломки», а, заодно, ось таку милу плашечку

Доступно на Google Play

Ну ось. Іграшку ми з вами написали. Залишається тільки її пройти і відзначити ще одну мрію дитинства реалізованої. А тепер, я, з вашого дозволу, все ж піду дописувати кнопочку «скасувати останній хід», поки особливо захопилися знайомі не розірвали мене за її відсутність остаточно

Щиро Ваш, портирующий Тетріс на пральну машинку, GrakovNe

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.