diff --git a/public/assets/images/slots/slots-abyssal-treasures-card.png b/public/assets/images/slots/slots-abyssal-treasures-card.png new file mode 100644 index 0000000..b07d1f8 Binary files /dev/null and b/public/assets/images/slots/slots-abyssal-treasures-card.png differ diff --git a/public/assets/images/slots/slots-pharaohs-fortune-card.png b/public/assets/images/slots/slots-pharaohs-fortune-card.png new file mode 100644 index 0000000..e023554 Binary files /dev/null and b/public/assets/images/slots/slots-pharaohs-fortune-card.png differ diff --git a/public/assets/tutorial-videos/farkel.mp4 b/public/assets/tutorial-videos/farkel.mp4 new file mode 100644 index 0000000..a41086c Binary files /dev/null and b/public/assets/tutorial-videos/farkel.mp4 differ diff --git a/public/assets/tutorial-videos/parchisi.mp4 b/public/assets/tutorial-videos/parchisi.mp4 new file mode 100644 index 0000000..85cf7ff Binary files /dev/null and b/public/assets/tutorial-videos/parchisi.mp4 differ diff --git a/public/data/slots-artwork.json b/public/data/slots-artwork.json index c0af9d0..806825a 100644 --- a/public/data/slots-artwork.json +++ b/public/data/slots-artwork.json @@ -199,7 +199,7 @@ { "id": "pharaohs-fortune-card", "key": "slots-pharaohs-fortune-card", - "path": null + "path": "/assets/images/slots/slots-pharaohs-fortune-card.png" }, { "id": "abyssal-treasures-bubble", @@ -254,7 +254,7 @@ { "id": "abyssal-treasures-card", "key": "slots-abyssal-treasures-card", - "path": null + "path": "/assets/images/slots/slots-abyssal-treasures-card.png" }, { "id": "dragons-hoard-king", diff --git a/public/src/games/farkel/FarkelGame.js b/public/src/games/farkel/FarkelGame.js index c9cc021..af0147e 100644 --- a/public/src/games/farkel/FarkelGame.js +++ b/public/src/games/farkel/FarkelGame.js @@ -6,10 +6,10 @@ import { api } from '../../services/api.js'; import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; -import { DICE, PIP_POS, PLAYER_COLOR_HEX, SCORING_REFERENCE } from './FarkelData.js'; +import { DICE, PIP_POS, PLAYER_COLOR_HEX, SCORING_REFERENCE, ON_BOARD_MIN } from './FarkelData.js'; import { createInitialState, rollDice, applySetAside, bank, farkleTurn, - scoreSelection, bestScoring, isGameOver, getWinners, + scoreSelection, bestScoring, breakdownScoring, isGameOver, getWinners, } from './FarkelLogic.js'; import { decideReroll, nextThinkDelay } from './FarkelAI.js'; @@ -45,6 +45,11 @@ const DEPTH = { die: 10, dieSel: 11, ui: 20, toast: 60, modal: 70, }; +// ─── Display helper ──────────────────────────────────────────────────────────── +function fmtPts(n) { + return n >= 1000 ? `${Math.floor(n / 1000)},${String(n % 1000).padStart(3, '0')}` : String(n); +} + export default class FarkelGame extends Phaser.Scene { constructor() { super('FarkelGame'); } @@ -89,6 +94,7 @@ export default class FarkelGame extends Phaser.Scene { this.buildTray(); this.buildDice(); this.buildShelf(); + this.buildExplainPanel(); this.buildScoringPanel(); this.buildScratchPaper(); this.buildButtons(); @@ -179,6 +185,38 @@ export default class FarkelGame extends Phaser.Scene { }).setOrigin(0, 0.5).setDepth(DEPTH.die); } + buildExplainPanel() { + const shelfLeft = TRAY_CX - TRAY_W / 2 + 4; + const shelfW = DICE * (SDIE + SDIE_GAP) + 12; + const shelfH = SDIE + 70; + const shelfY = SHELF_Y - SDIE / 2 - 46; + + const ex = shelfLeft + shelfW + 10; + const ew = TRAY_CX + TRAY_W / 2 - ex - 4; + const ey = shelfY; + const eh = shelfH; + + const bg = this.add.graphics().setDepth(DEPTH.die - 1); + bg.fillStyle(0x000000, 0.55); + bg.fillRoundedRect(ex, ey, ew, eh, 8); + bg.lineStyle(2, COLORS.accent, 0.6); + bg.strokeRoundedRect(ex, ey, ew, eh, 8); + + this.add.text(ex + 14, ey + 16, 'If you bank:', { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex, + }).setOrigin(0, 0.5).setDepth(DEPTH.die); + + this._explainBounds = { ex, ew, ey, eh }; + this._explainLines = []; + for (let i = 0; i < 4; i++) { + this._explainLines.push( + this.add.text(ex + 14, ey + 34 + i * 20, '', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex, + }).setOrigin(0, 0).setDepth(DEPTH.die), + ); + } + } + buildScoringPanel() { const rows = SCORING_REFERENCE.length; const rowH = 40; @@ -282,6 +320,7 @@ export default class FarkelGame extends Phaser.Scene { this.updateStatus(); this.updateTurnTotal(); this.updateControls(); + this.updateExplainPanel(); } drawDie(g, x, y, size, face, { selected = false, locked = false } = {}) { @@ -422,13 +461,11 @@ export default class FarkelGame extends Phaser.Scene { for (let seat = 0; seat < this.scratchRows.length; seat++) { const p = this.gs.players[seat]; const row = this.scratchRows[seat]; - const hasPending = this.gs.turn.kept > 0 || (this.gs.phase === 'awaitPick' && this.selectionScore() > 0); - const showTurn = (seat === cur && !isGameOver(this.gs) && hasPending); - if (showTurn) { - const total = (this.gs.phase === 'awaitPick') - ? this.gs.turn.kept + this.selectionScore() - : this.gs.turn.kept; - row.score.setText(`${p.score} +${total}`); + if (seat === cur && !isGameOver(this.gs)) { + // selectionScore() returns the combined cycle score (setAsideDice + selection). + // Adding priorKept gives the full prospective bank total in every phase. + const preview = (this.gs.turn.priorKept ?? 0) + this.selectionScore(); + row.score.setText(preview > 0 ? `${p.score} +${preview}` : String(p.score)); } else { row.score.setText(String(p.score)); } @@ -477,10 +514,56 @@ export default class FarkelGame extends Phaser.Scene { updateTurnTotal() { const gs = this.gs; - const kept = gs.turn.kept; - const sel = (gs.phase === 'awaitPick') ? this.selectionScore() : 0; - const total = kept + sel; - this.turnTotalText.setText(total > 0 ? `Turn total: ${total}` : ''); + const preview = (gs.turn.priorKept ?? 0) + this.selectionScore(); + this.turnTotalText.setText(preview > 0 ? `Turn total: ${preview}` : ''); + } + + updateExplainPanel() { + const gs = this.gs; + const p = gs.players[gs.current]; + const priorKept = gs.turn.priorKept ?? 0; + + // Combined set: already set-aside dice + pending selection (if any) + const combined = [ + ...gs.turn.setAsideDice, + ...(gs.phase === 'awaitPick' ? this.selectionValues() : []), + ]; + const groups = breakdownScoring(combined); + const cycleScore = groups.reduce((s, g) => s + g.points, 0); + const totalKept = priorKept + cycleScore; + const bankOk = p.onBoard || totalKept >= ON_BOARD_MIN; + + if (groups.length === 0 && priorKept === 0) { + this._explainLines.forEach((t) => t.setText('')); + return; + } + + // Gold = preview (selection pending), white = committed + const inPreview = gs.phase === 'awaitPick' && this.selected.size > 0 && this.selectionScore() > 0; + const detailColor = inPreview ? COLORS.goldHex : COLORS.textHex; + + let li = 0; + const maxDetail = this._explainLines.length - 1; // reserve last line for total + + if (priorKept > 0 && li < maxDetail) { + this._explainLines[li++].setText(`Prior rolls = ${fmtPts(priorKept)}`).setColor(COLORS.textHex); + } + + for (const g of groups) { + if (li >= maxDetail) break; + this._explainLines[li++].setText(`${g.label} = ${fmtPts(g.points)}`).setColor(detailColor); + } + + // Show total when there are multiple content lines, or when "need 500" applies + const contentLines = (priorKept > 0 ? 1 : 0) + groups.length; + if (totalKept > 0 && (contentLines > 1 || !bankOk) && li < this._explainLines.length) { + const note = bankOk ? '' : ' (need 500)'; + this._explainLines[li++] + .setText(`Total = ${fmtPts(totalKept)}${note}`) + .setColor(bankOk ? COLORS.accentHex : COLORS.dangerHex); + } + + for (let i = li; i < this._explainLines.length; i++) this._explainLines[i].setText(''); } updateControls() { @@ -500,16 +583,21 @@ export default class FarkelGame extends Phaser.Scene { // ── selection helpers ───────────────────────────────────────────────────────── selectionValues() { return [...this.selected].map((i) => this.gs.turn.rolled[i]); } + + // Returns the combined score of all set-aside dice this cycle plus the current + // selection. This IS the value that would become turn.kept if committed. + selectionScore() { + const combined = [...this.gs.turn.setAsideDice, ...this.selectionValues()]; + if (combined.length === 0) return 0; + const r = scoreSelection(combined); + return r.valid ? r.points : 0; + } + + // A selection is valid when the combined set (set-aside + selection) all score. selectionValid() { const values = this.selectionValues(); if (values.length === 0) return false; - return scoreSelection(values).valid; - } - selectionScore() { - const values = this.selectionValues(); - if (values.length === 0) return 0; - const r = scoreSelection(values); - return r.valid ? r.points : 0; + return scoreSelection([...this.gs.turn.setAsideDice, ...values]).valid; } // ── input ────────────────────────────────────────────────────────────────────── @@ -523,29 +611,8 @@ export default class FarkelGame extends Phaser.Scene { onScoreAll() { if (this.busy || !this.isHumanTurn() || this.gs.phase !== 'awaitPick') return; - const rolled = this.gs.turn.rolled; - const kept = this.gs.turn.setAsideDice; - - // Try all subsets of rolled dice, combine with kept, find best total score - let bestScore = 0; - let bestIndices = new Set(); - for (let mask = 0; mask < (1 << rolled.length); mask++) { - const subset = []; - for (let i = 0; i < rolled.length; i++) { - if (mask & (1 << i)) subset.push(rolled[i]); - } - const combined = [...kept, ...subset]; - const result = scoreSelection(combined); - if (result.valid && result.points > bestScore) { - bestScore = result.points; - bestIndices = new Set(); - for (let i = 0; i < rolled.length; i++) { - if (mask & (1 << i)) bestIndices.add(i); - } - } - } - - this.selected = bestIndices; + const { indices } = bestScoring(this.gs.turn.rolled, this.gs.turn.setAsideDice); + this.selected = new Set(indices); playSound(this, SFX.PIECE_CLICK); this.render(); } @@ -627,7 +694,7 @@ export default class FarkelGame extends Phaser.Scene { this.portraitCtrls[seat]?.controller?.playEmotion?.('upset'); break; } - const best = bestScoring(this.gs.turn.rolled); + const best = bestScoring(this.gs.turn.rolled, this.gs.turn.setAsideDice); this.selected = new Set(best.indices); this.render(); await this.delay(480); diff --git a/public/src/games/farkel/FarkelLogic.js b/public/src/games/farkel/FarkelLogic.js index e8b621d..8c23539 100644 --- a/public/src/games/farkel/FarkelLogic.js +++ b/public/src/games/farkel/FarkelLogic.js @@ -75,42 +75,38 @@ export function scoreSelection(dice) { return { valid: true, points: best }; } -// Greedy "take everything that scores" — returns the indices into `dice` to set -// aside and the points earned. Used by the AI and the human "Score all" helper. -export function bestScoring(dice) { - const counts = countsOf(dice); - const total = dice.length; - const candidates = []; - - // Greedy: all 1s/5s plus every N-of-a-kind. - { - const take = [0, 0, 0, 0, 0, 0, 0]; - let pts = 0; - for (let v = 1; v <= 6; v++) { - const c = counts[v]; - if (c >= 3) { take[v] = c; pts += bestGroupValue(v, c); } - else if (v === 1) { take[v] = c; pts += c * 100; } - else if (v === 5) { take[v] = c; pts += c * 50; } +// Find the best subset of `dice` (rolled) to set aside given already set-aside +// dice from this turn. Brute-forces all 2^n subsets — feasible for n ≤ 6 (63 +// iterations). Returns the indices of rolled dice and the resulting combined score. +export function bestScoring(dice, setAsideDice = []) { + const n = dice.length; + let bestPts = 0; + let bestIndices = []; + for (let mask = 1; mask < (1 << n); mask++) { + const subset = []; + const indices = []; + for (let i = 0; i < n; i++) { + if (mask & (1 << i)) { subset.push(dice[i]); indices.push(i); } + } + const res = scoreSelection([...setAsideDice, ...subset]); + if (res.valid && res.points > bestPts) { + bestPts = res.points; + bestIndices = indices; } - candidates.push({ take, pts }); } - // Straight / three pairs use all six dice. - if (total === 6 && [1, 2, 3, 4, 5, 6].every((v) => counts[v] === 1)) { - candidates.push({ take: [0, 1, 1, 1, 1, 1, 1], pts: 1500 }); - } - if (total === 6 && [1, 2, 3, 4, 5, 6].every((v) => counts[v] % 2 === 0)) { - candidates.push({ take: counts.slice(), pts: 1500 }); - } - - const best = candidates.reduce((a, b) => (b.pts > a.pts ? b : a)); - const need = best.take.slice(); - const indices = []; - dice.forEach((v, i) => { if (need[v] > 0) { indices.push(i); need[v]--; } }); - return { indices, points: best.pts }; + return { indices: bestIndices, points: bestPts }; } -export function hasScoring(dice) { - return bestScoring(dice).points > 0; +// A roll is NOT a farkle if any subset of the rolled dice, when combined with +// already set-aside dice from this turn, produces a valid scoring set. +export function hasScoring(dice, setAsideDice = []) { + const n = dice.length; + for (let mask = 1; mask < (1 << n); mask++) { + const subset = []; + for (let i = 0; i < n; i++) if (mask & (1 << i)) subset.push(dice[i]); + if (scoreSelection([...setAsideDice, ...subset]).valid) return true; + } + return false; } // ── state ──────────────────────────────────────────────────────────────────── @@ -142,7 +138,7 @@ export function createInitialState({ playerCount = 4, names = [], skills = {}, s } function resetTurn(state) { - state.turn = { rolled: [], available: DICE, kept: 0, setAsideDice: [], hotDice: false }; + state.turn = { rolled: [], available: DICE, kept: 0, priorKept: 0, setAsideDice: [], hotDice: false }; state.phase = 'awaitRoll'; } @@ -154,7 +150,7 @@ export function cloneState(s) { } // Roll the available dice into turn.rolled. Sets phase to awaitPick, or farkled -// when the roll scores nothing. +// when no subset of the rolled dice (combined with already set-aside dice) scores. export function rollDice(state) { const t = state.turn; const rng = state._rng; @@ -162,30 +158,36 @@ export function rollDice(state) { for (let i = 0; i < t.available; i++) out.push(1 + Math.floor(rng() * 6)); t.rolled = out; t.hotDice = false; - state.phase = hasScoring(out) ? 'awaitPick' : 'farkled'; + state.phase = hasScoring(out, t.setAsideDice) ? 'awaitPick' : 'farkled'; return state; } -// Set aside the dice at the given indices (into turn.rolled). Adds their points -// to the turn total. Triggers hot dice when all six are used. Returns true on a -// valid selection. +// Set aside the dice at the given indices (into turn.rolled). The turn score is +// always the combined value of ALL set-aside dice this cycle (not a running sum), +// so extending the set can unlock higher N-of-a-kind bonuses. Triggers hot dice +// when all available dice have been used. Returns true on a valid selection. export function applySetAside(state, indices) { const t = state.turn; const values = indices.map((i) => t.rolled[i]); - const res = scoreSelection(values); + const combined = [...t.setAsideDice, ...values]; + const res = scoreSelection(combined); if (!res.valid) return false; - t.kept += res.points; t.setAsideDice.push(...values); t.available -= values.length; t.rolled = []; if (t.available === 0) { - // Hot dice — every die scored, so roll all six again. + // Hot dice — carry this cycle's score into priorKept, then reset for new cycle. + t.priorKept += res.points; + t.kept = t.priorKept; t.available = DICE; t.setAsideDice = []; t.hotDice = true; + } else { + t.kept = t.priorKept + res.points; } + state.phase = 'awaitDecision'; return true; } @@ -238,3 +240,29 @@ function computeWinners(state) { export function isGameOver(state) { return state.phase === 'gameover'; } export function getWinners(state) { return state.winners; } + +// ── display helper ─────────────────────────────────────────────────────────── +// Returns an array of { label, points } describing the scoring groups in `dice`. +export function breakdownScoring(dice) { + if (!dice || dice.length === 0) return []; + const total = dice.length; + const counts = countsOf(dice); + if (total === 6) { + if ([1, 2, 3, 4, 5, 6].every((v) => counts[v] === 1)) return [{ label: 'Straight 1–6', points: 1500 }]; + if ([1, 2, 3, 4, 5, 6].every((v) => counts[v] % 2 === 0)) return [{ label: 'Three Pairs', points: 1500 }]; + } + const K = ['', '', '', 'Three', 'Four', 'Five', 'Six']; + const groups = []; + for (let v = 1; v <= 6; v++) { + const n = counts[v]; + if (n === 0) continue; + if (n >= 3) { + groups.push({ label: `${K[n]} ${v}s`, points: bestGroupValue(v, n) }); + } else if (v === 1) { + groups.push({ label: n === 1 ? 'Single 1' : 'Two 1s', points: n * 100 }); + } else if (v === 5) { + groups.push({ label: n === 1 ? 'Single 5' : 'Two 5s', points: n * 50 }); + } + } + return groups; +} diff --git a/public/src/games/slots/SlotsGame.js b/public/src/games/slots/SlotsGame.js index 24f442c..09eb81c 100644 --- a/public/src/games/slots/SlotsGame.js +++ b/public/src/games/slots/SlotsGame.js @@ -171,13 +171,15 @@ export default class SlotsGame extends Phaser.Scene { const art = this.add.image(0, -6, cardArtKey).setDisplaySize(w - 16, h - 90); card.add(art); } - const name = this.add.text(0, -h / 2 + 52, m.name.toUpperCase(), { - fontFamily: 'Righteous', fontSize: '30px', color: t.accentHex, align: 'center', - }).setOrigin(0.5).setShadow(0, 0, t.accentHex, 16); - const tag = this.add.text(0, -h / 2 + 86, m.tagline, { - fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, - }).setOrigin(0.5); - card.add([name, tag]); + if (!this.textures.exists(cardArtKey)) { + const name = this.add.text(0, -h / 2 + 52, m.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '30px', color: t.accentHex, align: 'center', + }).setOrigin(0.5).setShadow(0, 0, t.accentHex, 16); + const tag = this.add.text(0, -h / 2 + 86, m.tagline, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + }).setOrigin(0.5); + card.add([name, tag]); + } // Era badge. const era = this.add.text(w / 2 - 16, -h / 2 + 18, m.era.toUpperCase(), { @@ -233,8 +235,9 @@ export default class SlotsGame extends Phaser.Scene { for (let b = 0; b < bulbs; b++) { const lx = -w / 2 + 40 + (b * (w - 80)) / (bulbs - 1); const on = Math.sin(bulbState.phase + b * 0.9) > 0; + const ly = this.textures.exists(cardArtKey) ? 100 : (-h / 2 + 110); lights.fillStyle(on ? t.glow : 0x1a140c, on ? 0.95 : 0.8); - lights.fillCircle(lx, -h / 2 + 110, 5); + lights.fillCircle(lx, ly, 5); } }; drawBulbs();