Когда-то давно я учился в старших классах школы и у меня был очень популярный в то время мобильный телефон: Motorola C350. Именно на этом телефоне я впервые и познакомился с игрой Snood™ 21. Хотя Motorola C350 был в использовании у меня совсем недолго, я всё равно успел провести очень много времени в этом интересном и своеобразном карточном пасьянсе, играя в него в различных очередях и на очень скучных уроках. На мой взгляд, Snood™ 21 является просто отличной убивалкой времени и поможет скрасить томительное ожидание в самых различных ситуациях. Спустя годы, мой лучший друг напомнил мне об этой замечательной игре и я решил сделать её ремейк для Android OS, чтобы снова иметь возможность раскладывать карты по знакомым стратегиям и на современных смартфонах.
Snooder 21 на Motorola Droid 4 и Snood™ 21 на Motorola C350.
История Snood™ 21 берёт своё начало с оригинальной игры Snood, которую разработал David M. Dobson в 1996 году для персональных компьютеров Mac от Apple. Именно с этой игры и началась история незамысловатых и забавных персонажей, которые называются Снудами. Вселенная Снудов включает в себя огромное количество самых разных настольных, карточных и аркадных игр, которые теперь доступны на официальном сайте под различные платформы. К несчастью, я не смог найти там игру, аналогичную Snood™ 21. Видимо она выходила только на мобильные телефоны от Motorola.
Нативная игра Snood™ 21, запущенная на Motorola V150. Автор фотографии Никита Прокопчик (превью, увеличение по клику).
Разработчиком игры Snood™ 21 является компания THQ, которая в сотрудничестве с Motorola выпустила эту игру встроенной в прошивку различных бюджетных мобильных телефонов. THQ купила права использования имён персонажей и их изображений у идейного вдохновителя и даже создала Java-версию игры для монохромных телефонов с поддержкой Java. Помимо этого, существует и нативная версия для карманного компьютера Cybiko.
Правила игры довольно просты: необходимо помещать карты из колоды в столбцы, набирать в них 21 очко и стараться попасть в таблицу рекордов. Масти в игре заменены Снудами, кроме этого имеется несколько специальных карт, которые сразу очищают колонку. На подсчёт очков влияет столбец, в котором была собрана комбинация. Чем он правее, тем больше очков можно заработать. Игра заканчивается если истечёт время или заблокируются все столбцы. Более подробно правила Snood™ 21 описаны в инструкции, которая шла вместе с мобильным телефоном Motorola.
Страница из инструкции к мобильному телефону Motorola V150 с подробным описанием правил игры Snood™ 21.
В своём ремейке для Android OS я решил максимально сохранить геймплей и дизайн оригинальной игры. А поскольку права на название и изображения персонажей принадлежат компании Snood LLC., я решил переименовать игру в Snooder 21 и перерисовать спрайты главных героев. Новое название, конечно, не блещет оригинальностью, но зато остаётся в духе изначального.
Содержание:
1. Логика игры, отрисовка с помощью канваса класса SurfaceView
2. Обзор методов управления
3. Создание игровых ресурсов
4. Создание лаунчера с таблицей рекордов
5. Заключение, полезные ссылки и ресурсы
1. Логика игры, отрисовка с помощью канваса класса SurfaceView
Заниматься разработкой игры я решил в этот раз не в Eclipse, а в Android Studio, так как Google уже давно советует перейти именно на эту IDE.
Android Studio с открытым проектом игры Snooder 21 (превью, увеличение по клику).
Для отрисовки игрового контекста на экран я использую класс SnoodsSurfaceView, который является наследником системного класса SurfaceView и реализует интерфейсы SurfaceHolder.Callback и Runnable. Отрисовка, а также обсчёт игровой логики и очков происходит в отдельном потоке внутри перегруженного метода run():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Override public void run() { while (mIsRunning) { tick(); try { mMainCanvas = mSurfaceHolder.lockCanvas(); synchronized (mSurfaceHolder) { render(mMainCanvas); } } finally { if (mMainCanvas != null) { mSurfaceHolder.unlockCanvasAndPost(mMainCanvas); } } } } |
Из метода run() вызывается метод tick(), в котором описана основная логика игры:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
private void tick() { if (!mIsWinAnimation) { if (mIsDropingCard) { dropCard(); } int locked = 0; for (int i = 0; i < COLUMNS_COUNT; ++i) { if (columnsDecks[i].size() == 5 && columnScores[i] < 21) { columnScores[i] = 21; dropColumn(i, false); } if (columnScores[i] > 21) { lockColumn(i, true); } if (columnScores[i] == 21) { dropColumn(i, false); } if (lockColumns[i]) { locked++; } } if (locked == COLUMNS_COUNT) { mDeckIsEmpty = true; mIsGameOver = true; if (mPlayingGameOverSound) { SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_GAME_OVER); mPlayingGameOverSound = false; } } if (!toastShown) { if (mIsGameOver) { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_game_over).toString(), Toast.LENGTH_LONG); toastShown = true; } else if (mDeckIsEmpty) { if (mLevel == 4) { if (getCountOfLockColumns() == 0) { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_congrats).toString(), Toast.LENGTH_LONG); } else { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_game_over).toString(), Toast.LENGTH_LONG); mIsGameOver = true; } } else { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_next_level).toString(), Toast.LENGTH_LONG); } toastShown = true; } } if (mDeckIsEmpty) { if (!columnDropped) { dropAllColumns(); columnDropped = false; } if (allColumnsEmpty()) { if (!mIsGameOver) { mIsWinAnimation = true; SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_WIN); } else { snoodsScoreManager.checkHighScore(scores); } SnoodsGameActivity.toDebug("Game End! Game Over: " + mIsGameOver); resetGame(mIsGameOver); } } } else { // mIsWinAnimation == true switch (mLevel) { default: { if (x_anim_sprite % 100 == 0) { showBlinkLabel = !showBlinkLabel; } x_anim_sprite += drop_column_speed; if (x_anim_sprite > ORIGINAL_WIDTH + CARD_HEIGHT + (CARD_WIDTH * 6) + CARD_GAP) { mIsWinAnimation = false; x_anim_sprite = 0; } break; } case 1: { if (y_anim_sprite % 100 == 0) { showBlinkLabel = !showBlinkLabel; } y_anim_sprite += drop_column_speed; if (y_anim_sprite > ORIGINAL_HEIGHT + CARD_HEIGHT + (CARD_HEIGHT * 6) + CARD_GAP) { mIsWinAnimation = false; y_anim_sprite = 0; } break; } } } } |
После вызова tick() происходит вызов метода render(), который формирует итоговую картинку и выводит её на канвас, отвечающий непосредственно за вывод изображения на экран:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
private void render(Canvas canvas) { if (canvas != null) { if (!mIsWinAnimation) { // Draw background mBitmapCanvas.drawBitmap(mBackGroundBitmap, 0, 0, mMainPaint); // Draw score label mBitmapCanvas.drawBitmap(mLabels[4], 419, 2, mMainPaint); // Draw scores paintNumber(mBitmapCanvas, mMainPaint, scores, 600, 6, false, false); // Draw column scores paintColumnScores(mBitmapCanvas, mMainPaint); // Draw cards count paintNumber(mBitmapCanvas, mMainPaint, cardIndex, 20, 288, false, false); // Draw "L" letter mBitmapCanvas.drawBitmap(mChars[11], 97, 288, mMainPaint); // Draw level paintNumber(mBitmapCanvas, mMainPaint, mLevel, 97 + 29, 288, false, false); // Draw progress bar paintProgressBar(mBitmapCanvas, mMainPaint); // Draw time paintNumber(mBitmapCanvas, mMainPaint, secs, 104, 4, false, true); // Draw card decks drawCardDecs(mBitmapCanvas, mMainPaint); // Draw Highlight highlightColumn(mBitmapCanvas, mMainPaint, highlightColumn); // Draw cards if (cardIndex > 2) { mBitmapCanvas.drawBitmap(mNextCardBitmap, initialCardCoordX, initialCardCoordY - 10, mMainPaint); } if (cardIndex > 1) { mBitmapCanvas.drawBitmap(mNextCardBitmap, initialCardCoordX, initialCardCoordY - 5, mMainPaint); } if (!mDeckIsEmpty) { mBitmapCanvas.drawBitmap(mCurrentCardBitmap, mX_card_coord, mY_card_coord, mMainPaint); } // Draw FPS if (SnoodsSettings.showFps) { mMainPaint.setColor(Color.WHITE); mBitmapCanvas.drawText(getTimesPerSecond() + " fps", 70, 450, mMainPaint); mMainPaint.reset(); } } else { paintWinAnimation(mBitmapCanvas, mMainPaint); } if (SnoodsSettings.antialiasing) { mMainPaint.setFilterBitmap(true); } canvas.drawBitmap(mGameBitmap, mOriginalScreenRect, mOutputScreenRect, mMainPaint); } } |
Чтобы не делать наборы спрайтов для разных разрешений дисплеев Android-устройств, я решил рисовать всё на холсте размера 800×480, а потом этот холст растягивать на весь экран со сглаживанием или без. Это несколько неправильный подход, поскольку на слабых устройствах или на девайсах с большим разрешением экрана будет значительно проседать FPS. Эксперименты показали, что FPS проседает в основном из-за сглаживания, поэтому я решил сделать возможность его отключения. Поскольку этот подход позволял сэкономить огромное количество времени, я выбрал именно его.
Колода карт представляет из себя простой массив int[], длина которого изменяется в зависимости от сложности уровня игры. В начало массива добавляется необходимое количество обычных карт, а в конец массива добавляются шесть специальных карт, которые очищают столбец. После чего колода случайным образом тусуется. В методе flushDeck() и происходит вся эта кухня:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
private void flushDeck() { cardIndex = 6 + 13 + 13 * ((mLevel == 4) ? 3 : mLevel); mDeck = new int[cardIndex]; // Add cards for (int i = 0, j = 0, k = 0; i < cardIndex; ++i, ++j) { if (j > 12) { j = 0; } mDeck[i] = j; if (i >= cardIndex - 6) { mDeck[i] = 13 + k; // Jokers if (k < 2) { k++; } else { k = 0; } } } String c = ""; for (int i = 0; i < cardIndex; ++i) { c += mDeck[i] + " "; } SnoodsGameActivity.toDebug(c); int changeCard, tempCard; for (int r = 0; r < cardIndex; r++) { changeCard = mRandom.nextInt(cardIndex); tempCard = mDeck[r]; mDeck[r] = mDeck[changeCard]; mDeck[changeCard] = tempCard; } c = ""; for (int i = 0; i < cardIndex; ++i) { c += mDeck[i] + " "; } SnoodsGameActivity.toDebug(c); } |
По значениям из массива int[] на игровое поле рендерятся две карты: текущая и следующая. После того, как карта помещена в столбец, текущая карта подменяется следующей, а следующая — уже следующей по значению массива. И так до самого конца игровой колоды:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private void switchToNextCard() { cardIndex--; if (cardIndex > 0) { mCurrentCardBitmap = cardBitmaps[mDeck[cardIndex - 1]]; mCurrentCardBitmapToDeck = cardBitmaps[mDeck[cardIndex - 1] + 17]; if (cardIndex > 1) { mNextCardBitmap = cardBitmaps[mDeck[cardIndex - 2]]; } } else { mDeckIsEmpty = true; } mX_card_coord = initialCardCoordX; mY_card_coord = initialCardCoordY; } |
Каждый отдельный столбец представлен как ArrayList<Bitmap>, а все колонки вместе представлены как ArrayList<Bitmap>[]. К примеру, так выглядит метод добавления карты в определённый столбец:
1 2 3 4 5 6 |
private void addCardToColumn(int column) { columnDropped = false; SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_DROP); refreshScores(column); columnsDecks[column].add(mCurrentCardBitmapToDeck); } |
А методом drawCardDecs() на экран рендерятся все карты в колонках:
1 2 3 4 5 6 7 8 9 10 |
private void drawCardDecs(Canvas canvas, Paint paint) { for (int i = 0; i < COLUMNS_COUNT; ++i) { int listSize = columnsDecks[i].size(); for (int j = 0; j < listSize; j++) { int x = columnRects[i].centerX() - mX_card_grab_coord; int y = columnStartHeight + columnOffsets[i] + j * CARD_GAP; canvas.drawBitmap(columnsDecks[i].get(j), x, y, paint); } } } |
После набора 21 очка массив столбца просто очищается методом clear().
Простейшие анимации карт в игре представлены двумя способами. Первый напрямую зависит от количества кадров в секунду и может быть отрегулирован, а второй использует для анимации таймер с обратным отсчётом и зависит от времени. Анимации первого типа используются в основном для передвижения карт и их реализация неприлично проста: в методе tick() происходит изменение контролирующих расположение спрайта переменных-координат. Второй тип анимации используется для переключения спрайтов карт и реализован в классе SnoodsAnimationTimer, наследнике системного класса CountDownTimer со следующими методами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
private void animate(int offset) { for (int i = 0; i < columnsDecks[column].size(); ++i) { columnsDecks[column].set(i, bitmaps[offset + columnsDecksValue[column].get(i)]); } } private void animate(Bitmap bitmap) { for (int i = 0; i < columnsDecks[column].size(); ++i) { columnsDecks[column].set(i, bitmap); } } private void animateFirstFrame() { if (lock || lockColumns[column]) { animate(bitmaps[17 + 16]); } else { animate(17); } } private void animateSecondFrame() { if (lock || lockColumns[column]) { animate(bitmaps[16]); } else { animate(0); } } public int getCountOfLockColumns() { int lockedColumns = 0; for (int i = 0; i < SnoodsSurfaceView.COLUMNS_COUNT; ++i) { if (lockColumns[i]) { lockedColumns++; } } return lockedColumns; } @Override public void onTick(long millisUntilFinished) { if (lock) { if (playSound && getCountOfLockColumns() < 3) { SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_LOCK); playSound = false; } SnoodsLauncherActivity.doVibrate(SnoodsLauncherActivity.VIBRATE_SHORT); } if (firstFrame) { animateFirstFrame(); firstFrame = false; } else { animateSecondFrame(); firstFrame = true; } } @Override public void onFinish() { animateSecondFrame(); } |
Этот класс реализует простейшее попеременное переключение кадров. Основной игровой таймер тоже является наследником класса CountDownTimer и имплементирован в классе SnoodsGameTimer. После того, как время кончится, метод onFinish() просто вызывает конец игры. Ещё этот класс в методе onTick() контролирует переменную, которая отвечает за правильное отображение индикатора прогресса времени, который рисуется обычным прямоугольником.
1 2 3 4 5 6 7 8 9 10 11 |
@Override public void onTick(long millisUntilFinished) { if (!snoodsSurfaceView.mDeckIsEmpty) { snoodsSurfaceView.progressBarPercent += dec; snoodsSurfaceView.secs = (int) millisUntilFinished / 1000; if (snoodsSurfaceView.secs == 20) { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_hurry_up).toString(), Toast.LENGTH_SHORT); } } } |
Поскольку большая часть игровой логики и отрисовки заключена в классе SnoodsSurfaceView, он получился весьма громоздким. Если я найду свободное время, то обязательно сделаю рефакторинг и разобью его на отдельные классы. Примечательно, что вся игровая логика Snooder 21 была написана мной лишь за один вечер.
2. Обзор методов управления
В Snooder 21 я реализовал три варианта управления, первые два из них используют сенсорный экран, а третий — физическую клавиатуру. Поскольку я являюсь счастливым обладателем смартфонов с QWERTY-клавиатурой, третий метод я реализовал в первую очередь. Для обработки событий нажатий на кнопки достаточно перегрузить метод onKeyDown() в классе SnoodsSurfaceView. Не стоит забывать, что в конструкторе этого класса должен быть вызван метод requestFocus(), в противном случае события клавиатуры будут пролетать мимо.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_Q: case KeyEvent.KEYCODE_1: { putCardToColumn(1); break; } case KeyEvent.KEYCODE_W: case KeyEvent.KEYCODE_2: { putCardToColumn(2); break; } case KeyEvent.KEYCODE_E: case KeyEvent.KEYCODE_3: { putCardToColumn(3); break; } case KeyEvent.KEYCODE_R: case KeyEvent.KEYCODE_4: { putCardToColumn(4); break; } default: { highlightColumn = 0; break; } } return super.onKeyDown(keyCode, event); } |
Карты добавляются в столбец методом putCardToColumn(), который по сути лишь меняет управляющие переменные, а все изменения и добавление карты в колонку обеспечивает метод tick(), который вызывается в другом потоке.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public void putCardToColumn(int column) { if (!mIsDropingCard && !mDeckIsEmpty && !mIsDropingColumn && !mIsWinAnimation) { highlightColumn = column; putCardToColumn(columnRects[column - 1].centerX() - CARD_WIDTH / 2, columnRects[column - 1].centerY() - CARD_HEIGHT / 2); } } public void putCardToColumn(int x, int y) { mX_coord_from = x; mY_coord_from = y; mIsDropingCard = true; } |
Метод putCardToColumn() и его перегруженная версия используется и в реализации вариантов сенсорного управления. Первый вариант такого управления обеспечивает перетаскивание карт из колоды на необходимые столбцы, а второй перемещает верхнюю карту колоды в столбец, которого коснулся палец. Всё это поведение реализовано в перегруженном методе onTouchEvent() класса SnoodsGameActivity:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
@Override public boolean onTouchEvent(MotionEvent event) { if (!mSnoodsSurfaceView.mIsDropingCard && !mSnoodsSurfaceView.mDeckIsEmpty && !mSnoodsSurfaceView.mIsDropingColumn && !mSnoodsSurfaceView.mIsWinAnimation) { int[] winCoordinates = new int[2]; mSnoodsSurfaceView.getLocationInWindow(winCoordinates); int actionMasked = event.getActionMasked(); int x = convertX(event.getRawX()) - winCoordinates[0]; int y = convertY(event.getRawY()) - winCoordinates[1]; int x_c = x - SnoodsSurfaceView.mX_card_grab_coord; int y_c = y - SnoodsSurfaceView.mY_card_grab_coord; switch (actionMasked) { case MotionEvent.ACTION_DOWN: { mSnoodsSurfaceView.touchInDeckRect(x, y); int inColumnRect = mSnoodsSurfaceView.detectColumn(x, y); if (mSnoodsSurfaceView.mIsGrab) { mSnoodsSurfaceView.setCardCoords(x_c, y_c); } else if (inColumnRect > 0) { mSnoodsSurfaceView.setHighlightColumn(inColumnRect); mSnoodsSurfaceView.putCardToColumn(x_c, y_c); } break; } case MotionEvent.ACTION_MOVE: { if (mSnoodsSurfaceView.mIsGrab) { mSnoodsSurfaceView.setCardCoords(x_c, y_c); mSnoodsSurfaceView.setHighlightColumn(mSnoodsSurfaceView.detectColumn(x, y)); } break; } case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: { mSnoodsSurfaceView.mPlayingGrabSound = true; if (mSnoodsSurfaceView.mIsGrab) { mSnoodsSurfaceView.putCardToColumn(x_c, y_c); } break; } } } return super.onTouchEvent(event); } |
Поскольку я использую холст размера 800×480, мне приходится конвертировать все координаты касаний специальными методами:
1 2 3 4 5 6 7 |
public static int convertX(float x) { return Math.round(x * SnoodsSurfaceView.ORIGINAL_WIDTH / SnoodsSurfaceView.getmScreenWidth()); } public static int convertY(float y) { return Math.round(y * SnoodsSurfaceView.ORIGINAL_HEIGHT / SnoodsSurfaceView.getmScreenHeight()); } |
Для наглядной визуализации доступности столбца я использую простую подсветку полупрозрачным прямоугольником зелёного и красного цветов.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private void highlightColumn(Canvas canvas, Paint paint, int column) { if (column == 0) { return; } else { if (lockColumns[column - 1]) { paint.setColor(Color.parseColor("#77CF5B56")); } else { paint.setColor(Color.parseColor("#7733AF54")); } canvas.drawRect(columnRects[column - 1], paint); paint.reset(); } } |
Таким образом, при наведении карты на столбец он окрашивается в зелёный или красный цвет в зависимости от того, можно в него поместить карту или нет.
Подсветка активного столбца, скриншот с Motorola Photon Q (превью, увеличение по клику).
Управление автоматически отключается при проигрывании различных анимаций передвижений карт, чтобы избежать непредвиденных последствий.
3. Создание игровых ресурсов
Игровое поле Snooder 21 я попытался сделать максимально похожим на таковое из оригинальной игры и за несколько минут нарисовал его в свободном графическом редакторе GIMP, определив размеры холста в 800×480.
Игровое поле, скриншот с Motorola Photon Q (превью, увеличение по клику).
В качестве небольшой пасхалки я оставил векторное изображение Motorola C350 под колодой карт. Именно на этой модели мобильного телефона, которая являлась очень популярной в народе, и была оригинальная игра Snood™ 21. Это изображение можно увидеть пройдя уровень до конца и использовав последнюю карту.
Фоновая заставка, которая проигрывается при переходе на следующий уровень, тоже была нарисована мною в GIMP’е. На простом градиентном фоне, который символизирует небо, я использовал фильтр Gradient Flare для создания светила и группу из фильтров RGB Noise, Spread и Motion Blur для создания простой травы. Немного приправил тенями и, мне кажется, получилось достаточно симпатично:
Нарисованный в GIMP’е фон заставки.
В 2011 году Johannes Kopf, являющийся сотрудником компании Microsoft Research и профессор Dani Lischinski опубликовали научный доклад с описанием нового алгоритма депикселизации изображений, который значительно превосходит все существующие методы. Позже, в рамках инициативной программы по развитию проектов с открытым исходным кодом от компании Google — Google Summer of Code 2013, этот алгоритм реализовали в отдельной библиотеке libdepixelize, написаной на языке программирования C++. Позже эту библиотеку включили в состав свободного редактора векторной графики Inkscape.
Демонстрация результата работы нового алгоритма Личински-Кофа.
Благодаря работе этих замечательных учёных и редактору Inkscape у меня появилась возможность нарисовать забавные спрайты-смайлики для игровых карт. Я отрисовывал в GIMP’е пикселизированное изображение небольшого размера, затем импортировал его в Inkscape, использовал там инструмент Trace Pixel Art и получал необходимый спрайт.
Пример создания спрайта для игровой карты в программе Inkscape с помощью инструмента Trace Pixel Art.
Для того, чтобы спрайты в формате PNG весили ещё меньше, я обработал их специальной утилитой optipng, которая доступна в репозиториях большинства дистрибутивов GNU/Linux.
1 |
$ optipng -o7 game_sprite.png |
Использование этой утилиты позволило уменьшить размер PNG-спрайтов приблизительно на 20%.
Подходящие звуки для игры я долго и упорно отбирал на тематических сайтах freesound и OpenGameArt.Org. Все они были опубликованы под свободными лицензиями, авторов звуковых эффектов я отметил в документе Sounds_Licenses.txt в своём репозитории.
4. Создание лаунчера с таблицей рекордов
Для того, чтобы было удобно работать с различными опциями, я вынес все настройки в специальный лаунчер, который сохраняет их при выходе из приложения и восстанавливает при повторном запуске. В лаунчере можно определить скорость анимации или задать имя, которое будет использовано для записей в таблице рекордов, которая находится здесь же.
Лаунчер Snooder 21 и таблица рекордов, скриншоты с Motorola Droid 4 (превью, увеличение по клику).
Для законченности я добавил в лаунчер обложку, которую тоже нарисовал в GIMP’е и диалоги About и Help с информацией и правилами игры.
Диалоги About и Help, скриншоты с Motorola Photon Q (превью, увеличение по клику).
Таблица рекордов образована двумя объектами класса TextView: первый отображает имена, а второй количество набранных очков. Обновление таблицы рекордов осуществляется методом updateHighScoreTable(), который берёт нужную информацию из вложенного класса SnoodsSettings и соединяет всё в строки с переносами, которые и отображают на экране объекты класса TextView:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public static void updateHighScoreTable() { String players = ""; String scores = ""; for (int i = 0; i < HIGH_SCORE_PLAYERS; ++i) { players += SnoodsSettings.playerNames[i]; scores += SnoodsSettings.playerScores[i]; if (i < HIGH_SCORE_PLAYERS - 1) { players += "\n"; scores += "\n"; } } playerNamesView.setText(players); playerScoresView.setText(scores); } |
Управлением таблицей рекордов занимается класс SnoodsScoreManager, методы которого выполняют различную работу, например, генерируют уникальное имя игрока при первом запуске игры, или проверяют то, что набранные очки действительно нужно вносить в таблицу. Если игрок выставит некорректное, например, пустое имя, то этот класс заменит его на Player или обрежет до 11 символов.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
public static String generatePlayerName() { String modelName = Build.MANUFACTURER.subSequence(0, 3).toString(); modelName = modelName.toUpperCase(Locale.getDefault()); modelName += "-" + Build.MODEL; return normalizePlayerName(modelName); } private static String normalizePlayerName(String name) { if (name.equals("")) { name = "Player"; } if (name.length() > 11) { name = name.subSequence(0, 11).toString(); } return name; } private void saveHiScores() { if (SnoodsLauncherActivity.settingStorage != null) { SharedPreferences.Editor editor = SnoodsLauncherActivity.settingStorage.edit(); for (int i = 0; i < SnoodsLauncherActivity.HIGH_SCORE_PLAYERS; ++i) { editor.putString("player" + i, SnoodsSettings.playerNames[i]); editor.putInt("score" + i, SnoodsSettings.playerScores[i]); } editor.commit(); } else { SnoodsGameActivity.toDebug("Error: settingStorage is null!"); } } private void insertScore(String name, int score, int i) { if (i != -1) { String localObject = SnoodsSettings.playerNames[i]; String str = ""; int j = SnoodsSettings.playerScores[i]; int k = 0; for (int m = i + 1; m < SnoodsLauncherActivity.HIGH_SCORE_PLAYERS; m++) { k = SnoodsSettings.playerScores[m]; str = SnoodsSettings.playerNames[m]; SnoodsSettings.playerScores[m] = j; SnoodsSettings.playerNames[m] = localObject; j = k; localObject = str; } SnoodsSettings.playerNames[i] = name; SnoodsSettings.playerScores[i] = score; saveHiScores(); snoodsGameActivity.runOnUiThread(new Runnable() { @Override public void run() { SnoodsLauncherActivity.updateHighScoreTable(); } }); } } private int getScorePosition(int score) { for (int i = 0; i < SnoodsLauncherActivity.HIGH_SCORE_PLAYERS; ++i) { if (score > SnoodsSettings.playerScores[i]) { return i; } } return -1; } public int checkHighScore(int highScore) { SnoodsGameActivity.toDebug("Score is:" + highScore); int i = getScorePosition(highScore); if (i == -1) { return i; } String answer = snoodsGameActivity.getResources().getText(R.string.toast_high_score_ch1).toString() + " "; answer += highScore + " "; answer += snoodsGameActivity.getResources().getText(R.string.toast_high_score_ch2).toString(); if (SnoodsSettings.writeScores) { answer += " " + snoodsGameActivity.getResources().getText(R.string.toast_high_score_ch3).toString(); insertScore(playerName, highScore, i); } snoodsGameActivity.showToast(answer, Toast.LENGTH_SHORT); return i; } |
Метод insertScore() в своём теле запускает обновление таблицы рекордов в интерфейсном потоке. Набранные очки проверяются на рекорд при каждом проигрыше или при нажатии на клавишу Back во время игры.
5. Заключение, полезные ссылки и ресурсы
Создание ремейка игры Snood™ 21 на Android OS позволило мне разобраться в некоторых тонкостях сенсорного управления, овладеть базовыми навыками работы в мощнейших графических редакторах GIMP и Inkscape, а также использовать Android Studio вместе с системой автоматической сборки Gradle. Этот процесс дал мне огромное количество ценного опыта, я смог реализовать за короткое время весьма интересную карточную игру.
Размер установочного APK-пакета получился небольшим, всего 480 КБ. Основной вес приходится на спрайты и фоны достаточно большого разрешения. Поскольку я использовал только язык программирования Java, приложение должно отлично работать на всех доступных архитектурах процессоров, на которых работает Android OS.
Скачать APK-пакет Snooder 21, готовый для запуска на любом Android-устройстве, можно по этим ссылкам:
[Скачать APK-пакет Snooder 21, 480 КБ | Download Snooder 21 APK-package, 480 КB]
Все исходные коды и проект в целом выложен в репозиторий на ресурсе Github. Мои изменения и исходные файлы доступны под лицензией MIT, а лицензия CC BY 3.0 используется для всех созданных игровых ресурсов. Ссылка на репозиторий:
https://github.com/EXL/Snooder21
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
- Руководство по созданию клона игры Space Invaders с использованием отрисовки на SurfaceView;
- Исходники клона игры Babson Space Invaders на Github;
- Руководство по рисованию травы в GIMP;
- Inkscape tutorial: Tracing Pixel Art.
Update 06-MAR-2017: Прочитать информацию о новой версии моего ремейка Snooder 21 v1.1 с незначительными исправлениями и улучшениями можно по этой ссылке.
Update 05-MAY-2017: В проекте были обновлены компоненты Gradle, это ознаменовало выход новой версии Snooder 21 v1.2, скачать которую можно со странички проектов.
Update 12-JUL-2017: Пользователь GitHub’а под ником picsi попросил меня добавить игру Snooder 21 в маркет свободных и бесплатных приложений, который называется F-Droid. Я отправил программу на модерацию в специальный репозиторий и спустя три дня мой ремейк Snood™ 21 был добавлен в каталог этого маркета. Посмотреть страничку приложения можно по этой ссылке.
Прямо таки тема, как подарок на мой ДР
Спасибо за отзыв!
Спасибо! Давно искал эту игру. Кто-то даже её делал, но она кривая и не на всех моих девайсах запускалась. А тут бесплатно и без рекламы! респектос!
Спасибо за отзыв!
На С350 было фиг выиграешь и быстро-быстро приходилось нажимать для рекорда
Всё-равно я как-то на скучных уроках приноравливался и проходил 🙂
Но да, с первого раза игрушку не пройдёшь, тут ты прав.
Интересно, а где можно достать и другие приложения из Моторолы: синтезатор мелодий, Мотомикс?
Вряд ли кто-то занимался переносом простенького приложения MotoMixer на Android OS. К тому же для этой платформы доступны более профессиональные инструменты, например, MilkyTracker, SunVox и куча других.
Я кстать проверил несколько месяцев назад эту игрушку на старых часофонах 2012 года.
Жаль, что они у меня сломались и я их уже выкинул.
Удивительно, но игра завелась и даже можно было играть. Как и Astrosmash. Кадры правда дропались до 12-15 (для Astrosmash вообще пришлось настройки в ноль вогнать) но для логической игрушки вполне неплохо для устройства без графического ускорителя.
Интересно, спасибо за отзыв. Удивлён, что играбельно получилось.