[#7] Пишем игру Backjack (Очко). Даем игроку опомнится в конце раунда

Запускаемый код на JSFiddle. Полный код в конце статьи.

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

Нам нужно дать время пользователю чтобы понять чем закончился раунд. Для этого нужна ситуация когда отображены счет, руки компьютера и игрока и видно сообщение. По действию пользователя запускается новый раунд. Если присмотреться к существующему методу endRound, то видно что в нем смешаны аспекты завершения раунда и начала нового. Разнесем эти аспекты в отдельные методы.

endRound() {
	// перебора нет
	if (this.playerHandWeight <= 21 && this.computerHandWeigh <= 21) {
		if (this.playerHandWeight > this.computerHandWeigh) {
			this.playerWings += 1
		} else if (this.computerHandWeigh > this.playerHandWeight) {
			this.computerWings += 1
		}
		// Иначе ничья. Очков никому
	// Eсть перебор
	// Перебор человека
	} else if (this.playerHandWeight > 21 && this.computerHandWeigh <= 21) {
		this.computerWings += 1
			// Перебор компьютера
	} else if (this.computerHandWeigh > 21 && this.playerHandWeight <= 21) {
		this.playerWings += 1
	} else {
		// Перебор обоих. Очков никому
	}
	this.showMessage(this.getMessage())
	this.renderScore()
	this.renderComputerHand()
	this.renderPlayerHand()
	this.renderTurn()
}
startRound() {
	this.computerCards = []
	this.playersCards = []
	this.deck = this._createDeck()
	this.playersTurn = true

	this.renderScore()
	this.renderComputerHand()
	this.renderPlayerHand()
	this.renderTurn()
	this.clearMessage()
}

На этом этапе я запутался какие из renderX нужно вызывать и хочется просто вызывать одну функицю которая отрендерит все что нужно. Пока просто дублирую вызовы рендеров.

Начало раунда означает сброс рук, затусовывание колоды и сброс сообщения. Кстати, сброс сообщения означает убрать содержимое узла с текстом и скрытие кнопки. Отобразить сообщение означает вставить текст в DOM и сделать кнопку видимой.

clearMessage() {
	this.DOMMessage.innerHTML = ''
	this.DOMMessageAction.style.display = 'none'
}
	
showMessage(text) {
	this.DOMMessage.innerHTML = text
	this.DOMMessageAction.style.display = 'block'
}

Так как мы будем показывать сообщение только в конце раунда, то достаточно иметь только один обработчик события на кнопке. Нет необходимости жонглировать жонглировать тем какое действие нужно выполнить при нажатии на кнопку. У некоторых могут чесаться руки написать абстракцию “на всякий случай” чтобы можно было задавать любой обработчик при нажатии на кнопке в диалоге. Но мы не будем. Малый и прямолинейный код проще понять и проще изменить когда появится необходимость.

Интересный вопрос как смоделировать вывод сообщения об окончании раунда. Руки чешутся вставить логику выбора строки для сообщения в места где идет подсчет счета. Но чаще удобно “разносить” аспекты. Счет считаем в одном месте, а текст в другом. Позже такой “разнос” дает удобность понимания аспектов в отдельности. Нет большой проблемы “смешать” вывод сообщения с подсчетом выигрыша раунда. Но мы ради тренировки вынесем вычисление сообщения в отдельный метод getMessage.

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

getMessage() {
	// специальный случай когда копьютер не походил а пользователь умудрился набрать 21
	if (this.playerHandWeight === 21 && this.computerHandWeigh === 0) {
		return "Удача! Раунд за тобой, игрок."
	} else if (this.playerHandWeight > 21 && this.computerHandWeigh > 21) {
		return "Перебор у всех. Ничья."
	} else if (
		(this.playerHandWeight <= 21 && this.computerHandWeigh <= 21 && this.playerHandWeight > this.computerHandWeigh) ||
		(this.playerHandWeight <= 21 && this.computerHandWeigh > 21)) {
		return "Раунд за игроком."
	} else if (
		(this.playerHandWeight <= 21 && this.computerHandWeigh <= 21 && this.computerHandWeigh > this.playerHandWeight) || 
		(this.playerHandWeight > 21 && this.computerHandWeigh <= 21)) {
		return "Раунд за компьютером."
	} else if (this.playerHandWeight <= 21 && this.computerHandWeigh <= 21 && this.playerHandWeight === this.computerHandWeigh) {
		return "Ничья."
	} else {
		console.log('this.playerHandWeight', this.playerHandWeight)
		console.log('this.computerHandWeigh', this.computerHandWeigh)
		throw new Error('Не должно выполняться')
	}
}

Бонус. В прошлой версии кода была логическая ошибка:

pass() {
	this.playersTurn = true
	this.renderTurn()
	this.computerTurn()
}

Строка должна быть this.playersTurn = false. Ошибки такого рода неизбежны. Отлавливать их надо методически: или при код ревью или при тестировании всех возможных состояний в своем коде.

Весь код:

// Основной класс который управляет игрой а так же отображением состояния игры в DOM
class BlackJackGame {
	get playerHandWeight() {
		return this._getWeight(this.playersCards)
	}

	get computerHandWeigh() {
		return this._getWeight(this.computerCards)
	}

	constructor(rootNode) {

		// Ниже значения для демонстрации какие свойства будут у объекта игры.
		// Значения будут манипулироваться объектом игры в процессе игры
		this.playersCards = null
		this.computerCards = null
		this.deck = null
		this.playersTurn = null
		this.playerWings = null
		this.computerWings = null

		// Узлы в которые будет происходит отрисовка состояния игры. Удобно когда узлы существуют в течение всей игры. Тогда не придетс заморачиваться с поддержанием состосния (навешивание-убирание обработчиков событий)
		// Иногда для удобства отличия узлов от данных придумывают формат переменных, например, начинающиеся с $ это DOM узлы. Или дать всем префикс DOM. Тогда при чтении кода будет однозначно понятно с чем имеем дело
		this.DOMComputerScore = null
		this.DOMComputerCards = null
		this.DOMDeckOfCards = null
		this.DOMPlayerScore = null
		this.DOMPlayerCards = null
		this.DOMPass = null

		// Корневой элемет
		this.DOMRootNode = rootNode
		this.init()
	}

	// Функция-привязка ко внешнему миру, куда игра будет себя проэцировать.
	// Запускается разово, генерирует всю нужную разметку в переданом узле, создает начальное сотояние игры
	init() {
		this.playersCards = []
		this.computerCards = []
		this.deck = this._createDeck()
		this.playersTurn = true
		this.playerWings = 0
		this.computerWings = 0

		// Разметку с которой будет иметь дело игра лучше создавать самой игре
		// На начальных этапах фокусируемся на контейнерах - узлах в которые мы будем отображать аспекты игры.
		// На конечных этапах мы просто перенесем эти узлы в другие узлы или добавим оберток чтобы создать желаемую картинку. Код же самой игры менять не будет потому что мы отделим логику отображения от логики самой игры. 
		// Стремимся называть классы единообразно, сообразно аспекту за который отвечает узел. Классы нужны для того чтобы уникальным образом обратиться к элементу. Например блок карт бота и блок карт человека должны иметь разные классы (можно сделать общий класс чтобы поместить в него общие стили, но логику будем строить на уникальных классах).
		// Не критично, но важно стремиться использовать один и тот же набор категорий. Однообразно называть пользователя игры "игрок" а не "ползователь" в одном месте и "игрок" в другом. Но фишка в том что этого не всегда можно достичь даже по объективным причинам. Ну то отдельная тема, пока стремимся опираться на один словарь терминов.
		this.DOMRootNode.innerHTML = `
			<div class="computer-score"></div>
			<div class="computer-cards"></div>
			<div class="deck-of-cards">Колода</div>
			<div class="player-score"></div>
			<div class="player-cards"></div>
			<div class="message"></div>
			<button class="message-action">Далее</button>
			<button class="pass">Пасс</button>
		`

		// У некоторых, чаще начинающих но уже понапрограммировавших, или некритичных последователей принципов DRY могут чесаться руки написать функцию которая сгенерирует те же действия из структуры типа массива имен классов. Не надо. Код ниже куда проще и понятен всем вне зависимости от уровня. Плюс меняется куда проще если формат именования нужно будет сменить
		this.DOMComputerScore = this.DOMRootNode.querySelector('.computer-score')
		this.DOMComputerCards = this.DOMRootNode.querySelector('.computer-cards')
		this.DOMDeckOfCards = this.DOMRootNode.querySelector('.deck-of-cards')
		this.DOMPlayerScore = this.DOMRootNode.querySelector('.player-score')
		this.DOMPlayerCards = this.DOMRootNode.querySelector('.player-cards')
		this.DOMPass = this.DOMRootNode.querySelector('.pass')
		this.DOMMessage = this.DOMRootNode.querySelector('.message')
		this.DOMMessageAction = this.DOMRootNode.querySelector('.message-action')

		this.DOMDeckOfCards.addEventListener('click', () => {
			if (!this.playersTurn) {
				return
			}
			this.pickCard()
		})

		this.DOMPass.addEventListener('click', () => {
			if (!this.playersTurn) {
				return
			}
			this.pass()
		})
		
		this.DOMMessageAction.addEventListener('click', () => {
			this.startRound()
		})

		this.renderScore()
		this.clearMessage()
	}

	pickCard() {
		const card = this.deck.shift()
		this.playersCards.push(card)
		this.renderPlayerHand()
		if (this.playerHandWeight === 21) {
			this.endRound()
		}
	}

	renderPlayerHand() {
		this.DOMPlayerCards.innerHTML = `
			${ JSON.stringify(this.playersCards)}
		`
	}

	computerTurn() {
		const card = this.deck.shift()
		this.computerCards.push(card)
		this.renderComputerHand()
		if (this.computerHandWeigh < 18) {
			setTimeout(() => {
				this.computerTurn()
			}, 500)
		} else {
			this.endRound()
		}
	}

	renderComputerHand() {
		this.DOMComputerCards.innerHTML = `
			${ JSON.stringify(this.computerCards) }
		`
	}

	pass() {
		this.playersTurn = false
		this.renderTurn()
		this.computerTurn()
	}
	
	renderTurn() {
		this.DOMPass.disabled = !this.playersTurn
	}

	_getWeight(hand) {
		return hand.reduce((sum, card) => {
			return sum + card.weight
		}, 0)
	}
	
	endRound() {
		// перебора нет
		if (this.playerHandWeight <= 21 && this.computerHandWeigh <= 21) {
			if (this.playerHandWeight > this.computerHandWeigh) {
				this.playerWings += 1
			} else if (this.computerHandWeigh > this.playerHandWeight) {
				this.computerWings += 1
			}
			// Иначе ничья. Очков никому
		// Eсть перебор
		// Перебор человека
		} else if (this.playerHandWeight > 21 && this.computerHandWeigh <= 21) {
			this.computerWings += 1
				// Перебор компьютера
		} else if (this.computerHandWeigh > 21 && this.playerHandWeight <= 21) {
			this.playerWings += 1
		} else {
			// Перебор обоих. Очков никому
		}
		this.showMessage(this.getMessage())
		this.renderScore()
		this.renderComputerHand()
		this.renderPlayerHand()
		this.renderTurn()
	}

	startRound() {
		this.computerCards = []
		this.playersCards = []
		this.deck = this._createDeck()
		this.playersTurn = true

		this.renderScore()
		this.renderComputerHand()
		this.renderPlayerHand()
		this.renderTurn()
		this.clearMessage()
	}
	
	getMessage() {
		// специальный случай когда копьютер не походил а пользователь умудрился набрать 21
		if (this.playerHandWeight === 21 && this.computerHandWeigh === 0) {
			return "Удача! Раунд за тобой, игрок."
		} else if (this.playerHandWeight > 21 && this.computerHandWeigh > 21) {
			return "Перебор у всех. Ничья."
		} else if (
			(this.playerHandWeight <= 21 && this.computerHandWeigh <= 21 && this.playerHandWeight > this.computerHandWeigh) ||
			(this.playerHandWeight <= 21 && this.computerHandWeigh > 21)) {
			return "Раунд за игроком."
		} else if (
			(this.playerHandWeight <= 21 && this.computerHandWeigh <= 21 && this.computerHandWeigh > this.playerHandWeight) || 
			(this.playerHandWeight > 21 && this.computerHandWeigh <= 21)) {
			return "Раунд за компьютером."
		} else if (this.playerHandWeight <= 21 && this.computerHandWeigh <= 21 && this.playerHandWeight === this.computerHandWeigh) {
			return "Ничья."
		} else {
			console.log('this.playerHandWeight', this.playerHandWeight)
			console.log('this.computerHandWeigh', this.computerHandWeigh)
			throw new Error('Не должно выполняться')
		}
	}
	
	clearMessage() {
		this.DOMMessage.innerHTML = ''
		this.DOMMessageAction.style.display = 'none'
	}
	
	showMessage(text) {
		this.DOMMessage.innerHTML = text
		this.DOMMessageAction.style.display = 'block'
	}

	renderScore() {
		this.DOMComputerScore.innerHTML = this.computerWings
		this.DOMPlayerScore.innerHTML = this.playerWings
	}

	// Обычно создание и перемешивание колоды разделяется. Но ради простоты конечного кода я совмещаю эти процессы.
	_createDeck() {
		const suits = ['hearts', 'clubs', 'diamonds', 'spades']
		const cards = [{
			name: 'tuz',
			weight: 11
		}, {
			name: 'korol',
			weight: 4
		}, {
			name: 'dama',
			weight: 3
		}, {
			name: 'valet',
			weight: 2
		}, {
			name: '10',
			weight: 10
		}, {
			name: '9',
			weight: 9
		}, {
			name: '8',
			weight: 8
		}, {
			name: '7',
			weight: 7
		}, {
			name: '6',
			weight: 6
		}]
		const deck = []
		for (let suit of suits) {
			for (let cardProto of cards) {
				deck.push({
					suit: suit,
					name: cardProto.name,
					weight: cardProto.weight
				})
			}
		}
		deck.sort(() => {
			return Math.random() > 0.5 ? 1 : -1
		})

		return deck
	}
}

new BlackJackGame(document.querySelector('.game'))

Продолжил дорабатывать игру. Добавил монеты которые будут передвигаться к игроку который выиграл раунд.

Создал блок game-coins вместе с дочерними блоками game-coin . Всего дочерних блоков 5, так как раундов будет 5. Когда сторона выигрывает, то монета должна удалятся с блока game-coins и переходить в блок счёта одной из сторон.

Но как сделать так чтобы дочерний элемент одного блока удалился и перешёл в другой пытался сделать, но не получилось вот пример
const playerCoin = this.DOMGameCoins.lastChild;
this.DOMPlayerWings.append(playerCoin);

Выполняется этот код в функции endRound();
Вот вся функция :

endRound(){
if (this.scorePlayerHand <= 21 && this.scoreComputerHand <= 21) {
if(this.scorePlayerHand > this.scoreComputerHand) {
this.playerWings += 1;
const playerCoin = this.DOMGameCoins.lastChild;
this.DOMPlayerWings.append(playerCoin);
}else if (this.scoreComputerHand > this.scorePlayerHand) {
this.computerWings += 1;
const computerCoin = this.DOMGameCoins.firstChild;
this.DOMComputerWings.append(computerCoin);
}
}else if (this.scorePlayerHand >= 21 && this.scoreComputerHand <= 21) {
this.computerWings += 1;
const computerCoin = this.DOMGameCoins.firstChild;
this.DOMComputerWings.append(computerCoin);
}else if (this.scoreComputerHand >= 21 && this.scorePlayerHand <= 21) {
this.playerWings += 1;
const playerCoin = this.DOMGameCoins.lastChild;
this.DOMPlayerWings.append(playerCoin);
}
this.showMessage(this.getMessage());
this.renderScore();
this.renderTurn();
}

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

Вопрос в том как реализовать эффект полета монеты. Удаление в одном месте и перемещение в другое это то как наш мозг моделирует перемещение монеты. А думать нужно в рамках возможностей DOM и браузера.

Я бы для эффекта перемещения бы использовать css transitions Using CSS transitions - CSS: Cascading Style Sheets | MDN в комбинации с абсолютным позиционированием элемента.

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