From e020f2a4b6505122dc762a6bb29bbc25f949c7ab Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 20 Dec 2025 22:10:19 +1100 Subject: [PATCH 1/3] First cut of Mahjong scorecard --- site/mahjong/guide.html | 100 +++++++++++++++++++ site/mahjong/index.html | 81 ++++++++++++++++ site/mahjong/main.js | 209 ++++++++++++++++++++++++++++++++++++++++ site/mahjong/models.js | 152 +++++++++++++++++++++++++++++ site/mahjong/style.css | 14 +++ 5 files changed, 556 insertions(+) create mode 100644 site/mahjong/guide.html create mode 100644 site/mahjong/index.html create mode 100644 site/mahjong/main.js create mode 100644 site/mahjong/models.js create mode 100644 site/mahjong/style.css diff --git a/site/mahjong/guide.html b/site/mahjong/guide.html new file mode 100644 index 0000000..43ceeb7 --- /dev/null +++ b/site/mahjong/guide.html @@ -0,0 +1,100 @@ + + + + + + Clocks - Tools + + + + +
+
+

Mahjong Score Card

+

Score entry guide

+
+
+
+

Use the following notation to enter player scores.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NotationMeaningScore
mMahjong
psPung of simples
ptPung of terminals
phPung of honours
xpsExposed pung of simples
xptExposed pung of terminals
xphExposed pung of honours
ksKong of simples
ktKong of terminals
khKong of honours
xksExposed kong of simples
xktExposed kong of terminals
xkhExposed kong of honours
pdPair of dragons
pwPair of winds (either player or prevailing)
bBonus (flower or season)
+
+ + \ No newline at end of file diff --git a/site/mahjong/index.html b/site/mahjong/index.html new file mode 100644 index 0000000..1c070f4 --- /dev/null +++ b/site/mahjong/index.html @@ -0,0 +1,81 @@ + + + + + + Mahjong Score Card - Tools + + + + +
+
+

Mahjong Score Card

+
+
+ + + + + + + \ No newline at end of file diff --git a/site/mahjong/main.js b/site/mahjong/main.js new file mode 100644 index 0000000..49609ff --- /dev/null +++ b/site/mahjong/main.js @@ -0,0 +1,209 @@ +import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { GameState, getGameState, setGameState, calculateScore } from "./models.js"; + +function emitGameStateEvent(details) { + // window.setTimeout(() => { + let ev = new CustomEvent("gamestatechanged", { detail: details }); + window.dispatchEvent(ev); + // }, 1); +} + + +window.Stimulus = Application.start(); + +Stimulus.register("scorecard", class extends Controller { + static targets = ["prevailing"]; + + initialize() { + } + + connect() { + // this._rebuildTable(); + // this.element.classList.remove("hidden"); + } + + handleGameState(ev) { + switch (ev.detail.mode) { + case "endround": + this.element.classList.add("hidden"); + break; + case "startgame": + case "nextround": + this._rebuildTable(); + this.element.classList.remove("hidden"); + break; + } + } + + endRound(ev) { + ev.preventDefault(); + console.log("end round"); + emitGameStateEvent({mode: "endround"}); + } + + _rebuildTable() { + let gameState = getGameState(); + + let tbl = document.createElement("table"); + tbl.classList.add("scorecard"); + + let thead = document.createElement("thead"); + let thr = document.createElement("tr"); + let headRows = gameState.players.map(player => { + let th = document.createElement("th"); + th.textContent = player.name + " (" + player.wind + ")"; + return th; + }); + thr.append(...headRows); + thead.append(thr); + tbl.append(thead); + + let tbody = document.createElement("tbody"); + for (let r of gameState.rounds) { + let tr = document.createElement("tr"); + for (let c of r) { + let td = document.createElement("td"); + td.textContent = c.s; + if (c.w) { + td.classList.add("winner"); + td.textContent += " 🏆"; + } + tr.append(td); + } + tbody.append(tr); + } + tbl.append(tbody); + + let tfoot = document.createElement("tfoot"); + for (let t of gameState.playerTotals()) { + let td = document.createElement("td"); + td.textContent = t; + tfoot.append(td); + } + tbl.append(tfoot); + + this.element.querySelector("table.scorecard").replaceWith(tbl); + + this.prevailingTarget.textContent = `Prevailing: ${gameState.prevaling}`; + } +}); + +Stimulus.register("newgame", class extends Controller { + static targets = ["playerName"]; + + connect() { + this.element.classList.remove("hidden"); + } + + handleGameState(ev) { + switch (ev.detail.mode) { + case "startgame": + case "endround": + case "nextround": + this.element.classList.add("hidden"); + break; + } + } + + startGame() { + let players = []; + this.playerNameTargets.forEach(el => { + if (el.value != "") { + players.push(el.value); + } + }); + + setGameState(GameState.newGame(players)); + this.element.classList.add("hidden"); + emitGameStateEvent({mode: "startgame"}); + } +}); + +Stimulus.register("endround", class extends Controller { + static targets = ["form", "input", "wind"]; + + connect() { + // this.element.classList.remove("hidden"); + } + + handleGameState(ev) { + switch (ev.detail.mode) { + case "startgame": + case "nextround": + this.element.classList.add("hidden"); + break; + case "endround": + this._prepForms(); + this.element.classList.remove("hidden"); + break; + } + } + + updatePreview(ev) { + ev.preventDefault(); + + let scoreExprs = this._readScoreExprs(); + let nextRound = getGameState().determineNextRound(scoreExprs); + + for (let i in nextRound.roundScores) { + let previewElem = this.element.querySelector(`.preview[data-player="${i}"]`); + previewElem.textContent = nextRound.roundScores[i].score; + if (parseInt(i) === nextRound.roundWinner) { + previewElem.textContent += " 🏆"; + } + } + + if (nextRound.windsBump) { + this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will be East next round`; + } else { + this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will remain East next round`; + } + } + + nextRound(ev) { + ev.preventDefault(); + + let scoreExprs = this._readScoreExprs(); + getGameState().startNextRound(scoreExprs); + + emitGameStateEvent({mode: "nextround"}); + } + + _prepForms() { + let newFormElems = getGameState().players.map((player, i) => { + let outerDiv = document.createElement("div"); + + let label = document.createElement("label"); + label.textContent = player.name + " (" + player.wind + ")"; + label.setAttribute("for", `player-${i}`); + + let div = document.createElement("div"); + div.classList.add("score-input-group"); + + let input = document.createElement("input"); + input.name = `player-${i}`; + input.dataset["endroundTarget"] = "input"; + input.dataset["action"] = "keyup->endround#updatePreview"; + input.dataset["player"] = i; + + let preview = document.createElement("div"); + preview.classList.add("preview"); + preview.textContent = "0"; + preview.dataset["player"] = i; + + div.append(input, preview); + outerDiv.append(label, div); + return outerDiv; + }); + this.formTarget.replaceChildren(...newFormElems); + } + + _readScoreExprs() { + let scoreExprs = []; + for (let playerIndex in getGameState().players) { + let thisScoreExpr = this.formTarget.querySelector(`input[data-player="${playerIndex}"]`).value; + scoreExprs.push(thisScoreExpr); + } + return scoreExprs; + } +}); \ No newline at end of file diff --git a/site/mahjong/models.js b/site/mahjong/models.js new file mode 100644 index 0000000..9180afe --- /dev/null +++ b/site/mahjong/models.js @@ -0,0 +1,152 @@ +const windDistributions = [ + ["E"], + ["E", "W"], + ["E", "S", "N"], + ["E", "S", "W", "N"], + ["E", "S", "W", "N", "X"], +]; + +const scoreTokens = { + 'm': 1, + 'ps': 1, + 'pt': 1, + 'ph': 1, + 'xps': 1, + 'xpt': 1, + 'xph': 1, + 'ks': 1, + 'kt': 1, + 'kh': 1, + 'xks': 1, + 'xkt': 1, + 'xkh': 1, + 'pd': 1, + 'pw': 1, + 'b': 1, +} + +function parseTokens(str) { + // Split on whitespace and filter out empty strings + const tokens = str.split(/\s+/).filter(token => token.length > 0); + + const pattern = /^([0-9]*)([a-z]+)$/; + const result = []; + + for (const token of tokens) { + const match = token.match(pattern); + if (match) { + result.push({ + number: match[1] ? parseInt(match[1], 10) : null, + letters: match[2] + }); + } + } + + return result; +} + +export function calculateScore(str) { + const tokens = parseTokens(str); + let o = {score: 0, winner: false}; + + for (const token of tokens) { + if (!(token.letters in scoreTokens)) { + continue; + } + o.score += scoreTokens[token.letters] * (token.number || 1); + if (token.letters === 'm') { + o.winner = true; + } + } + return o; +} + + +export class GameState { + constructor(players, rounds) { + this.players = players; + this.rounds = rounds; + this.bumpWind = 0; + this.prevaling = "E"; + } + + static newGame(playerNames) { + let players = playerNames.map(name => { return { name: name } }); + let windDistribution = windDistributions[players.length - 1]; + for (let i = 0; i < players.length; i++) { + players[i].wind = windDistribution[i]; + } + + let rounds = []; + return new GameState(players, rounds); + } + + determineNextRound(playerScoreExprs) { + let roundScores = playerScoreExprs.map(calculateScore); + let roundWinner = roundScores.findIndex(rs => rs.winner); + + let currentEast = this.players.findIndex(p => p.wind === 'E'); + let nextRoundEast = currentEast; + if (roundWinner !== currentEast) { + nextRoundEast = (currentEast + 1) % this.players.length; + } + + let windsBump = roundWinner !== currentEast; + let nextPrevailing = this.prevaling; + if (windsBump) { + nextPrevailing = windDistributions[3][((this.bumpWind + 1) / this.players.length)|0]; + } + + return { + roundScores: roundScores, + roundWinner: roundWinner, + nextRoundEast: nextRoundEast, + windsBump: roundWinner !== currentEast, + nextPrevailing: nextPrevailing, + } + } + + playerTotals() { + let scores = this.players.map(p => 0); + + for (let i = 0; i < this.rounds.length; i++) { + for (let j = 0; j < this.rounds[i].length; j++) { + scores[j] += this.rounds[i][j].s; + } + } + + return scores; + } + + startNextRound(playerScoreExprs) { + let nr = this.determineNextRound(playerScoreExprs); + + this.rounds.push(nr.roundScores.map((s, i) => { + if (i === nr.roundWinner) { + return { s: s.score, w: true }; + } + return { s: s.score }; + })); + + let windDistribution = windDistributions[this.players.length - 1]; + for (let i = 0; i < this.players.length; i++) { + let pi = (nr.nextRoundEast + i) % this.players.length; + this.players[pi].wind = windDistribution[i]; + } + + this.prevaling = nr.nextPrevailing; + if (nr.windsBump) { + this.bumpWind++; + } + } +} + +let gameState = new GameState(); + +export function getGameState() { + return gameState; +} + +export function setGameState(gs) { + gameState = gs; +} \ No newline at end of file diff --git a/site/mahjong/style.css b/site/mahjong/style.css new file mode 100644 index 0000000..7a26656 --- /dev/null +++ b/site/mahjong/style.css @@ -0,0 +1,14 @@ +.hidden { + display: none; +} + +.main-table { + table-layout: fixed; +} + +.score-input-group { + display: grid; + gap: 15px; + grid-template-columns: 2fr 1fr; + align-items: baseline; +} \ No newline at end of file From 1f8394f23bbe19691526dab4bc7778b3727c33bf Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 20 Dec 2025 22:32:30 +1100 Subject: [PATCH 2/3] Added the end game --- site/mahjong/index.html | 24 ++++++++++++++++++------ site/mahjong/main.js | 20 +++++++++++++++++++- site/mahjong/models.js | 1 - site/mahjong/style.css | 7 +++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/site/mahjong/index.html b/site/mahjong/index.html index 1c070f4..e1dce3f 100644 --- a/site/mahjong/index.html +++ b/site/mahjong/index.html @@ -43,10 +43,12 @@ -
- - +
Prevailing: E
+
+ + +
+
+
+
+ + +
+
diff --git a/site/mahjong/main.js b/site/mahjong/main.js index 49609ff..f9ba03c 100644 --- a/site/mahjong/main.js +++ b/site/mahjong/main.js @@ -24,6 +24,7 @@ Stimulus.register("scorecard", class extends Controller { handleGameState(ev) { switch (ev.detail.mode) { + case "newgame": case "endround": this.element.classList.add("hidden"); break; @@ -37,10 +38,19 @@ Stimulus.register("scorecard", class extends Controller { endRound(ev) { ev.preventDefault(); - console.log("end round"); emitGameStateEvent({mode: "endround"}); } + endGame(ev) { + ev.preventDefault(); + + if (!confirm("Are you sure you want to end the game?")) { + return; + } + + emitGameStateEvent({mode: "newgame"}); + } + _rebuildTable() { let gameState = getGameState(); @@ -102,6 +112,9 @@ Stimulus.register("newgame", class extends Controller { case "nextround": this.element.classList.add("hidden"); break; + case "newgame": + this.element.classList.remove("hidden"); + break; } } @@ -130,6 +143,7 @@ Stimulus.register("endround", class extends Controller { switch (ev.detail.mode) { case "startgame": case "nextround": + case "newgame": this.element.classList.add("hidden"); break; case "endround": @@ -160,6 +174,10 @@ Stimulus.register("endround", class extends Controller { } } + goBack(ev) { + emitGameStateEvent({mode: "startgame"}); + } + nextRound(ev) { ev.preventDefault(); diff --git a/site/mahjong/models.js b/site/mahjong/models.js index 9180afe..48d1762 100644 --- a/site/mahjong/models.js +++ b/site/mahjong/models.js @@ -108,7 +108,6 @@ export class GameState { playerTotals() { let scores = this.players.map(p => 0); - for (let i = 0; i < this.rounds.length; i++) { for (let j = 0; j < this.rounds[i].length; j++) { scores[j] += this.rounds[i][j].s; diff --git a/site/mahjong/style.css b/site/mahjong/style.css index 7a26656..1940ccb 100644 --- a/site/mahjong/style.css +++ b/site/mahjong/style.css @@ -11,4 +11,11 @@ gap: 15px; grid-template-columns: 2fr 1fr; align-items: baseline; +} + +.btns { + display: flex; + gap: 10px; + justify-content: space-between; + align-items: center; } \ No newline at end of file From c5420c97eb779ac50f856faa8f281fffd4b9f2e4 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 21 Dec 2025 10:56:49 +1100 Subject: [PATCH 3/3] Finished the scorecard --- site/index.html | 1 + site/mahjong/guide.html | 26 ++++++++++++++++ site/mahjong/index.html | 12 ++++++-- site/mahjong/main.js | 46 ++++++++++++++++++---------- site/mahjong/models.js | 68 ++++++++++++++++++++++++++++------------- site/mahjong/style.css | 41 +++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 40 deletions(-) diff --git a/site/index.html b/site/index.html index c71b5cc..444f37f 100644 --- a/site/index.html +++ b/site/index.html @@ -24,6 +24,7 @@
  • Gradient Bands
  • Two-letter Country Codes
  • Timestamp Converter
  • +
  • Mahjong Scorecard
  • diff --git a/site/mahjong/guide.html b/site/mahjong/guide.html index 43ceeb7..0609f8f 100644 --- a/site/mahjong/guide.html +++ b/site/mahjong/guide.html @@ -32,66 +32,92 @@ m Mahjong + 20 ps Pung of simples + 4 pt Pung of terminals + 8 ph Pung of honours + 8 xps Exposed pung of simples + 2 xpt Exposed pung of terminals + 4 xph Exposed pung of honours + 4 ks Kong of simples + 16 kt Kong of terminals + 32 kh Kong of honours + 32 xks Exposed kong of simples + 8 xkt Exposed kong of terminals + 16 xkh Exposed kong of honours + 16 pd Pair of dragons + 2 pw Pair of winds (either player or prevailing) + 2 b Bonus (flower or season) + 4 + + + p + Point (used for adjusting scores) + 1 + + + d + Penalty (used for adjusting scores) + -1 diff --git a/site/mahjong/index.html b/site/mahjong/index.html index e1dce3f..3ac7778 100644 --- a/site/mahjong/index.html +++ b/site/mahjong/index.html @@ -78,15 +78,21 @@
    -

    -
    -
    +
    +
    +
    +
    + + diff --git a/site/mahjong/main.js b/site/mahjong/main.js index f9ba03c..584bcec 100644 --- a/site/mahjong/main.js +++ b/site/mahjong/main.js @@ -2,13 +2,10 @@ import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/di import { GameState, getGameState, setGameState, calculateScore } from "./models.js"; function emitGameStateEvent(details) { - // window.setTimeout(() => { - let ev = new CustomEvent("gamestatechanged", { detail: details }); - window.dispatchEvent(ev); - // }, 1); + let ev = new CustomEvent("gamestatechanged", { detail: details }); + window.dispatchEvent(ev); } - window.Stimulus = Application.start(); Stimulus.register("scorecard", class extends Controller { @@ -18,8 +15,6 @@ Stimulus.register("scorecard", class extends Controller { } connect() { - // this._rebuildTable(); - // this.element.classList.remove("hidden"); } handleGameState(ev) { @@ -75,8 +70,8 @@ Stimulus.register("scorecard", class extends Controller { let td = document.createElement("td"); td.textContent = c.s; if (c.w) { + td.textContent += " 🀄"; td.classList.add("winner"); - td.textContent += " 🏆"; } tr.append(td); } @@ -94,7 +89,7 @@ Stimulus.register("scorecard", class extends Controller { this.element.querySelector("table.scorecard").replaceWith(tbl); - this.prevailingTarget.textContent = `Prevailing: ${gameState.prevaling}`; + this.prevailingTarget.textContent = `Prevailing: ${gameState.prevailing}`; } }); @@ -102,7 +97,6 @@ Stimulus.register("newgame", class extends Controller { static targets = ["playerName"]; connect() { - this.element.classList.remove("hidden"); } handleGameState(ev) { @@ -133,10 +127,9 @@ Stimulus.register("newgame", class extends Controller { }); Stimulus.register("endround", class extends Controller { - static targets = ["form", "input", "wind"]; + static targets = ["form", "input", "wind", "prevailing"]; connect() { - // this.element.classList.remove("hidden"); } handleGameState(ev) { @@ -148,6 +141,7 @@ Stimulus.register("endround", class extends Controller { break; case "endround": this._prepForms(); + this._updatePreview(); this.element.classList.remove("hidden"); break; } @@ -155,7 +149,10 @@ Stimulus.register("endround", class extends Controller { updatePreview(ev) { ev.preventDefault(); + this._updatePreview(); + } + _updatePreview() { let scoreExprs = this._readScoreExprs(); let nextRound = getGameState().determineNextRound(scoreExprs); @@ -163,15 +160,23 @@ Stimulus.register("endround", class extends Controller { let previewElem = this.element.querySelector(`.preview[data-player="${i}"]`); previewElem.textContent = nextRound.roundScores[i].score; if (parseInt(i) === nextRound.roundWinner) { - previewElem.textContent += " 🏆"; + previewElem.classList.add("winner"); + previewElem.textContent += " 🀄"; + } else { + previewElem.classList.remove("winner"); } } if (nextRound.windsBump) { - this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will be East next round`; + this.windTarget.textContent = ` will be East next round`; } else { - this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will remain East next round`; + this.windTarget.textContent = ` will remain East next round`; } + let nextWindNameElem = document.createElement("strong"); + nextWindNameElem.textContent = getGameState().players[nextRound.nextRoundEast].name; + this.windTarget.prepend(nextWindNameElem); + + this.prevailingTarget.textContent = `Next prevailing: ${nextRound.nextPrevailing}`; } goBack(ev) { @@ -224,4 +229,13 @@ Stimulus.register("endround", class extends Controller { } return scoreExprs; } -}); \ No newline at end of file +}); + +document.addEventListener("DOMContentLoaded", () => { + let game = getGameState(); + if (game !== null) { + emitGameStateEvent({mode: "startgame"}); + } else { + emitGameStateEvent({mode: "newgame"}); + } +}) \ No newline at end of file diff --git a/site/mahjong/models.js b/site/mahjong/models.js index 48d1762..d49ee27 100644 --- a/site/mahjong/models.js +++ b/site/mahjong/models.js @@ -7,22 +7,24 @@ const windDistributions = [ ]; const scoreTokens = { - 'm': 1, - 'ps': 1, - 'pt': 1, - 'ph': 1, - 'xps': 1, - 'xpt': 1, - 'xph': 1, - 'ks': 1, - 'kt': 1, - 'kh': 1, - 'xks': 1, - 'xkt': 1, - 'xkh': 1, - 'pd': 1, - 'pw': 1, - 'b': 1, + 'm': 20, + 'ps': 4, + 'pt': 8, + 'ph': 8, + 'xps': 2, + 'xpt': 4, + 'xph': 4, + 'ks': 16, + 'kt': 32, + 'kh': 32, + 'xks': 8, + 'xkt': 16, + 'xkh': 16, + 'pd': 2, + 'pw': 2, + 'b': 4, + 'p': 1, + 'd': -1, } function parseTokens(str) { @@ -67,7 +69,7 @@ export class GameState { this.players = players; this.rounds = rounds; this.bumpWind = 0; - this.prevaling = "E"; + this.prevailing = "E"; } static newGame(playerNames) { @@ -78,7 +80,21 @@ export class GameState { } let rounds = []; - return new GameState(players, rounds); + let gs = new GameState(players, rounds); + gs.save(); + return gs; + } + + static load() { + let json = localStorage.getItem('mahjong-scorecard'); + if (!json) { + return null; + } + let o = JSON.parse(json); + let gs = new GameState(o.p, o.r); + gs.bumpWind = o.b; + gs.prevailing = o.w; + return gs; } determineNextRound(playerScoreExprs) { @@ -92,7 +108,7 @@ export class GameState { } let windsBump = roundWinner !== currentEast; - let nextPrevailing = this.prevaling; + let nextPrevailing = this.prevailing; if (windsBump) { nextPrevailing = windDistributions[3][((this.bumpWind + 1) / this.players.length)|0]; } @@ -133,14 +149,24 @@ export class GameState { this.players[pi].wind = windDistribution[i]; } - this.prevaling = nr.nextPrevailing; + this.prevailing = nr.nextPrevailing; if (nr.windsBump) { this.bumpWind++; } + this.save(); + } + + save() { + localStorage.setItem('mahjong-scorecard', JSON.stringify({ + p: this.players, + r: this.rounds, + b: this.bumpWind, + w: this.prevailing, + })); } } -let gameState = new GameState(); +let gameState = GameState.load(); export function getGameState() { return gameState; diff --git a/site/mahjong/style.css b/site/mahjong/style.css index 1940ccb..4f1eed1 100644 --- a/site/mahjong/style.css +++ b/site/mahjong/style.css @@ -1,3 +1,7 @@ +.container { + margin-block-end: 20px; +} + .hidden { display: none; } @@ -18,4 +22,41 @@ gap: 10px; justify-content: space-between; align-items: center; + margin-block-end: 12px; +} + +@media (max-width: 600px) { + .btns { + flex-direction: column; + gap: 20px; + } + + .btns :nth-child(1) { + align-self: start; + } + + .btns :nth-child(2) { + align-self: end; + } +} + +.winner { + color: #9B2318; + font-weight: bold; +} + + +th { + background: #E2E2E2; +} + +@media (prefers-color-scheme: dark) { + th { + background: #474747; + } + + .winner { + color: #F06048; + font-weight: bold; + } } \ No newline at end of file