Когда-то давно я учился в старших классах школы и у меня был очень популярный в то время мобильный телефон: 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 вообще пришлось настройки в ноль вогнать) но для логической игрушки вполне неплохо для устройства без графического ускорителя.
Интересно, спасибо за отзыв. Удивлён, что играбельно получилось.