I’m trying to do an assignment where I have to create the card game Game of Hearts.
Here is an example game (scroll down for rules): https://cardgames.io/hearts/
Right now, I am trying to implement the part where the player selects 3 cards to pass to the player next to them, and the bots will also automatically do the same thing after pressing the ‘pass’ button. However, I can’t seem to get the pass button to appear. I also believe I have to put each card in a container to make them clickable, but I’m not 100% how to go about implementing that as well. I am only supposed to be updating the view, not the model or controller.
Here is the model:
import {HU} from "./hearts_utils.js";
export class HeartsModel extends EventTarget {
#playerNames;
#state;
#scorelog;
#hands;
#current_trick;
#collected_tricks;
#passing;
constructor() {
super();
this.#playerNames = {
north: null,
east: null,
south: null,
west: null
};
this.#state = 'uninitialized';
this.#scorelog = [];
this.#hands = {
north: null,
east: null,
south: null,
west: null
};
this.#collected_tricks = {
north: [],
east: [],
south: [],
west: []
};
}
// Private methods
#dealCards () {
let deck = [];
['spades', 'hearts', 'diamonds', 'clubs'].forEach(suit => {
[2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14].forEach(rank => {
deck.push(new Card(suit, rank));
})
});
deck = deck.map((card) => [card, Math.random()]).sort((a,b) => a[1]-b[1]).map((cpair) => cpair[0]);
return {
north: new Hand(deck.slice(0,13)),
east: new Hand(deck.slice(13,26)),
south: new Hand(deck.slice(26,39)),
west: new Hand(deck.slice(39,52))
};
}
// These methods are only available to controller to update model.
// They should never be called from view objects.
initialize (north_name, east_name, south_name, west_name) {
if (this.#state != 'uninitialized') return;
this.#playerNames.north = north_name;
this.#playerNames.east = east_name;
this.#playerNames.south = south_name;
this.#playerNames.west = west_name;
this.#scorelog = [];
this.setupGame('right');
};
passCards (cards_to_pass) {
if (this.#state != 'passing') return;
HU.positions.forEach(p => this.#hands[p].remove(cards_to_pass[p]));
HU.positions.forEach(p => {
let pass_to = HU.passing_maps[this.#passing][p];
this.#hands[pass_to].add(cards_to_pass[p]);
});
this.#state = 'playing';
let lead = HU.positions.find(p => this.#hands[p].contains(Card.TWO_OF_CLUBS));
this.#current_trick = new Trick(lead);
this.dispatchEvent(new Event('stateupdate'));
this.dispatchEvent(new Event('trickstart'));
};
playCardIntoTrick (position, card) {
if (this.#state != 'playing') return;
this.#hands[position].remove([card]);
this.#current_trick.playCard(card);
this.dispatchEvent(new CustomEvent('trickplay', {detail: {
position: position,
card: card
}}));
if (this.#current_trick.isComplete()) {
this.dispatchEvent(new Event('trickend'));
}
};
collectTrick (position) {
if (this.#state != 'playing') return;
this.#collected_tricks[position].push(this.#current_trick);
this.dispatchEvent(new CustomEvent('trickcollected', {detail: {
position: position,
trick: this.#current_trick}
}));
this.#current_trick = null;
};
setupTrick (position) {
if (this.#state != 'playing') return;
this.#current_trick = new Trick(position);
this.dispatchEvent(new Event('trickstart'));
};
updateScoreLog (scorelog_entry, moonshooter) {
this.#scorelog.push(scorelog_entry);
this.dispatchEvent(new CustomEvent('scoreupdate', {detail: {
entry: scorelog_entry,
moonshooter: moonshooter
}}));
};
setupGame (passing) {
if ((this.#state == 'complete') || (this.#state == 'passing')) return;
this.#passing = passing;
this.#hands = this.#dealCards();
this.#current_trick = null;
this.#collected_tricks = {
north: [],
east: [],
south: [],
west: []
};
this.#state = 'passing';
this.dispatchEvent(new Event('stateupdate'));
};
matchOver () {
this.#state = 'complete';
this.dispatchEvent(new Event('stateupdate'));
};
// These methods are available to view objects to query model information
getState () {
return this.#state;
}
getPlayerName (position) {
return this.#playerNames[position];
};
getCurrentGamePoints (position) {
return this.#collected_tricks[position].reduce((sum, trick) => sum + trick.getPoints(), 0);
};
getScoreLog () {
return this.#scorelog;
};
getScore (position) {
return this.#scorelog.reduce((score, entry) => score + entry[position], 0);
}
getPassing() {
return this.#passing;
}
getCurrentTrick () {
return this.#current_trick;
}
getTricksLeft () {
return 13 - HU.positions.reduce((sum, p) => sum += this.#collected_tricks[p].length, 0);
}
getHand (position) {
return this.#hands[position];
}
getCollectedTricks(position) {
return this.#collected_tricks[position];
}
}
export class Card {
#suit
#rank
static TWO_OF_CLUBS = new Card('clubs', 2);
static QUEEN_OF_SPADES = new Card('spades', 12);
constructor (suit, rank) {
this.#suit = suit;
this.#rank = rank;
}
toString() {
return `${this.getRankName()} of ${this.getSuit()}`;
}
getSuit() {
return this.#suit;
}
getRank() {
return this.#rank;
}
getRankName() {
let honors_map = {};
honors_map[11] = 'jack';
honors_map[12] = 'queen';
honors_map[13] = 'king';
honors_map[14] = 'ace';
return this.#rank < 11 ? this.#rank.toString() : honors_map[this.#rank];
}
equals(other) {
return (other.getRank() == this.#rank) && (other.getSuit() == this.#suit);
}
}
export class Trick {
#lead;
#next_to_play;
#played_by_position;
static #next_to_play_map = {
north: 'east',
east: 'south',
south: 'west',
west: 'north'
};
constructor(lead) {
this.#lead = lead;
this.#next_to_play = lead;
this.#played_by_position = {
north: null,
east: null,
south: null,
west: null
}
}
getLead() {
return this.#lead;
}
getLeadSuit() {
return this.getCard(this.getLead()).getSuit();
}
nextToPlay () {
return this.#next_to_play;
}
playCard (card) {
this.#played_by_position[this.#next_to_play] = card;
this.#next_to_play = this.isComplete() ? null : Trick.#next_to_play_map[this.#next_to_play];
}
getCard(position) {
return this.#played_by_position[position];
}
isComplete() {
return !HU.positions.find(p => this.#played_by_position[p] == null);
};
getPoints() {
return HU.positions.map(p => this.#played_by_position[p])
.filter(c => c != null)
.reduce((points, c) => points +
(c.equals(Card.QUEEN_OF_SPADES) ? 13 :
((c.getSuit() == 'hearts') ? 1 : 0)), 0);
}
toString() {
return `next to play: ${this.#next_to_play}
north: ${this.#played_by_position.north}
east : ${this.#played_by_position.east}
south: ${this.#played_by_position.south}
west : ${this.#played_by_position.west}
`
}
}
export class Hand extends EventTarget {
#cards;
constructor (cards) {
super();
this.#cards = cards;
}
contains (card) {
return this.#cards.some(c => c.equals(card));
}
hasSuit(suit) {
return this.#cards.some(c => c.getSuit() == suit);
}
hasOnlyHearts() {
return !this.#cards.some(c => c.getSuit() != 'hearts');
}
add (cards) {
if (cards.length == 0) return;
this.#cards.push(...cards);
this.dispatchEvent(new CustomEvent('update', {detail:
{type: 'add', cards: [...cards]}}));
}
remove (cards) {
if (cards.length == 0) return;
cards.forEach((c_to_remove) => {
this.#cards = this.#cards.filter((c) => !c.equals(c_to_remove));
});
this.dispatchEvent(new CustomEvent('update', {detail:
{type: 'remove', cards: [...cards]}}));
}
getCards () {
return [...this.#cards];
}
toString() {
return `Hearts : ${this.#cards.filter(c=> c.getSuit() == 'hearts').sort((a,b) => a.getRank() - b.getRank()).map(c => c.getRankName()).join()}
Spades : ${this.#cards.filter(c=> c.getSuit() == 'spades').sort((a,b) => a.getRank() - b.getRank()).map(c => c.getRankName()).join()}
Diamonds: ${this.#cards.filter(c=> c.getSuit() == 'diamonds').sort((a,b) => a.getRank() - b.getRank()).map(c => c.getRankName()).join()}
Clubs : ${this.#cards.filter(c=> c.getSuit() == 'clubs').sort((a,b) => a.getRank() - b.getRank()).map(c => c.getRankName()).join()}
`;
}
}
Here is the controller:
import { HU } from "./hearts_utils.js";
import { Card } from "./hearts_model.js";
export class HeartsController {
#model;
#cards_to_pass;
#hearts_broken;
constructor(model) {
this.#model = model;
this.#model.addEventListener('trickend', () => this.#handleTrickEnd());
}
#doAsync() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
startGame(north_name, east_name, south_name, west_name) {
this.#cards_to_pass = {
north: [],
east: [],
south: [],
west: []
};
this.#hearts_broken = false;
this.#doAsync().then(() => {
this.#model.initialize(north_name, east_name, south_name, west_name);
});
}
passCards(position, cards) {
if (this.#model.getState() != 'passing') {
alert('Controller error: attempt to pass cards when not in passing state');
return;
}
if (this.#model.getPassing() == 'none') {
alert('Controller error: attempt to pass cards when passing is none');
return;
}
if (cards.length != 3) {
alert('Controller error: attempt to pass more/less than three cards');
return;
}
let hand = this.#model.getHand(position);
if (cards.some(c => !hand.contains(c))) {
alert('Controller error: attempt to pass a card not in the hand of position');
return;
}
if (this.#cards_to_pass[position].length != 0) {
alert('Controller error: attempt to pass cards twice');
return;
}
this.#cards_to_pass[position] = [...cards];
if (!HU.positions.find(p => this.#cards_to_pass[p].length == 0)) {
this.#doAsync().then(() => {
this.#model.passCards(this.#cards_to_pass);
this.#cards_to_pass = {
north: [],
east: [],
south: [],
west: []
}
});
}
}
isPlayable(position, card) {
let cur_trick = this.#model.getCurrentTrick();
let hand = this.#model.getHand(position);
if (cur_trick.getLead() == position) {
// If lead of first trick in game, then only 2 of clubs is playable.
if (this.#model.getTricksLeft() == 13) {
return card.equals(Card.TWO_OF_CLUBS);
}
// Can only lead hearts if hearts are broken or hand only has hearts.
if (card.getSuit() == 'hearts') {
if (!this.#hearts_broken) {
return hand.hasOnlyHearts();
}
}
return true;
} else {
let lead_card = cur_trick.getCard(cur_trick.getLead());
if (!lead_card) {
return false;
}
if (!hand.hasSuit(lead_card.getSuit())) {
return true;
}
return card.getSuit() == lead_card.getSuit();
}
}
playCard(position, card) {
if (this.#model.getState() != 'playing') {
alert('Controller error: playCard called when not in playing state.');
return;
}
if (this.#model.getCurrentTrick().nextToPlay() != position) {
alert('Controller error: attempt to play card out of position');
return;
}
if (!this.#model.getHand(position).contains(card)) {
alert('Controller error: attmept to play card not in hand');
return;
}
if (!this.isPlayable(position, card)) {
alert('Controller error: attmept to play unplayable card');
return;
}
this.#doAsync().then(() => {
this.#model.playCardIntoTrick(position, card);
this.#hearts_broken ||= (card.getSuit() == 'hearts');
});
}
#handleTrickEnd() {
// Figure out who won.
let cur_trick = this.#model.getCurrentTrick();
let winner = cur_trick.getLead();
let winning_card = cur_trick.getCard(winner);
HU.positions.forEach(position => {
if (winner != position) {
let card = cur_trick.getCard(position);
if ((card.getSuit() == winning_card.getSuit()) &&
(card.getRank() > winning_card.getRank())) {
winning_card = card;
winner = position;
}
}
});
this.#doAsync().then(() => this.#model.collectTrick(winner))
.then(() => {
if (this.#model.getTricksLeft() > 0) {
this.#model.setupTrick(winner);
return false;
} else {
// Game's over.
// Create scorelog entry (detect shooting the moon)
// Update scorelog
// Detect possible match end.
// Figure out next passing mode and set up next game.
// let scorelog_entry = HU.positions.reduce((entry, pos) => entry[pos] = this.#model.getCurrentGamePoints(pos), {});
let scorelog_entry = {
north: this.#model.getCurrentGamePoints('north'),
east: this.#model.getCurrentGamePoints('east'),
south: this.#model.getCurrentGamePoints('south'),
west: this.#model.getCurrentGamePoints('west'),
};
let moonshooter = HU.positions.find(p => scorelog_entry[p] == 26);
if (moonshooter) {
HU.positions.forEach(p => {
scorelog_entry[p] = (scorelog_entry[p] + 26) % 52;
});
} else {
moonshooter = null;
}
this.#model.updateScoreLog(scorelog_entry, moonshooter);
return true;
}
})
.then((game_over) => {
if (game_over) {
if (HU.positions.find(p => this.#model.getScore(p) >= 100)) {
this.#model.matchOver();
} else {
let next_passing = HU.next_passing_map[this.#model.getPassing()];
this.#hearts_broken = false;
this.#cards_to_pass = {
north: [],
east: [],
south: [],
west: []
};
this.#model.setupGame(next_passing);
if (next_passing == 'none') {
this.#doAsync().then(() => this.#model.passCards(this.#cards_to_pass));
}
}
}
});
}
}
And here is the view (only file that can be altered)
import {HeartsRobotKmp} from "./hearts_robot_kmp.js";
import {Card, Hand, Trick} from "./hearts_model.js";
import {HU} from "./hearts_utils.js";
export class HeartsView {
#model
#controller
constructor(model, controller) {
this.#model = model;
this.#controller = controller;
this.dealPressed = false;
}
render(render_div) {
this.render_div = render_div;
render_div.innerHTML = '';
this.#model.addEventListener("stateupdate", () => this.update());
this.#model.addEventListener("trickplay", () => this.updateTrick());
this.#model.addEventListener("scoreupdate", () => this.updateScores());
if (this.#model.getState() === 'uninitialized') {
this.renderNameSetup();
} else {
this.renderGameTable();
this.update();
}
}
renderNameSetup() {
this.render_div.innerHTML = `
<div class="name-setup">
<h2>Welcome to Hearts!</h2>
<label>Your Name: <input type="text" id="player-name" placeholder="You (South)"></label>
<label>West Player: <input type="text" id="west-name" ></label>
<label>North Player: <input type="text" id="north-name"></label>
<label>East Player: <input type="text" id="east-name"></label>
<button id="confirm-names">Start Game</button>
</div>
`;
document.getElementById('confirm-names').addEventListener('click', () => {
const south = document.getElementById('player-name').value || "You";
const west = document.getElementById('west-name').value;
const north = document.getElementById('north-name').value;
const east = document.getElementById('east-name').value;
this.#model.getPlayerName('north');
this.#model.getPlayerName('east');
this.#model.getPlayerName('south');
this.#model.getPlayerName('west');
this.#controller.startGame(north, east, south, west);
});
}
renderGameTable() {
const names = {
north: this.#model.getPlayerName('north'),
east: this.#model.getPlayerName('east'),
south: this.#model.getPlayerName('south'),
west: this.#model.getPlayerName('west')
}
this.render_div.innerHTML = `
<div id="game-table" style="display: grid; grid-template-areas:
'north north north'
'west center east'
'south south south';
gap: 20px; justify-items: center; align-items: center;">
<div id="north" style="grid-area: north;">${names.north}
<div id="north-hand" class="hand"></div>
</div>
<div id="west" style="grid-area: west;">${names.west}
<div id="west-hand" class="hand"></div>
</div>
<div id="center" style="grid-area: center;">
<div id="trick-area">Trick Goes Here</div>
<button id="deal-button">Deal</button>
<button id="pass-button" style="display:none;">Pass</button>
<div id="score-table">Scores Go Here</div>
</div>
<div id="east" style="grid-area: east;">${names.east}
<div id="east-hand" class="hand"></div>
</div>
<div id="south" style="grid-area: south;">${names.south}
<div id="south-hand" class="hand"></div>
</div>
</div>
`;
document.getElementById("deal-button").addEventListener("click", () => {
this.dealPressed = true;
this.renderHands();
});
}
update() {
const state = this.#model.getState();
if (!document.getElementById("game-table")) {
this.renderGameTable();
}
if (state === 'passing') {
if (this.dealPressed) {
this.showPassCardsUI();
}
} else if (state === 'playing') {
this.showPlayableCards();
this.updateTrick();
} else if (state === 'complete') {
this.showWinner();
}
this.updateScores();
}
renderHands() {
const positions = ["north", "east", "south", "west"];
positions.forEach(position => {
const hand = this.#model.getHand(position); // A Hand object
if (!hand) return;
const cards = hand.getCards(); // Array of Card objects
const container = document.getElementById(`${position}-hand`);
container.innerHTML = '';
cards.forEach(card => {
const cardDiv = document.createElement("div");
cardDiv.className = "card";
cardDiv.textContent = `${card.getRankName()} of ${card.getSuit()}`;
container.appendChild(cardDiv);
});
});
}
showPassCardsUI() {
const southHandDiv = document.getElementById('south-hand');
const passButton = document.getElementById('pass-button');
passButton.style.display = 'inline-block';
const selectedCards = new Set();
// Make each card clickable
[...southHandDiv.children].forEach(cardDiv => {
cardDiv.addEventListener('click', () => {
const cardText = cardDiv.textContent;
cardDiv.classList.toggle('selected');
if (selectedCards.has(cardText)) {
selectedCards.delete(cardText);
} else if (selectedCards.size < 3) {
selectedCards.add(cardText);
}
passButton.disabled = selectedCards.size !== 3;
});
});
passButton.disabled = true;
passButton.addEventListener('click', () => {
// Convert text labels back to actual Card objects from model
const southHand = this.#model.getHand('south').getCards();
const selectedCardObjs = [...selectedCards].map(text =>
southHand.find(c => `${c.getRankName()} of ${c.getSuit()}` === text)
);
// Player passes cards
this.#controller.passCard("south", selectedCardObjs);
// Other players pass random cards
for (const pos of ["north", "east", "west"]) {
const hand = this.#model.getHand(pos).getCards();
const randomCards = this.getRandomCards(hand, 3);
this.#controller.passCard(pos, randomCards);
}
passButton.style.display = 'none'; // Hide after passing
});
}
getRandomCards(cards, count) {
const shuffled = [...cards].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
}
showPlayableCards() { /* To be implemented */ }
updateTrick() { /* To be implemented */ }
updateScores() { /* To be implemented */ }
showWinner() { /* To be implemented */ }
renderMatchOver() {
// todo
}
}
any help would be appreciated, Thank you so much!