From 09dd8a70b2e5ac9f0f7d4f882da5ab23f45628ca Mon Sep 17 00:00:00 2001 From: Didictateur Date: Tue, 18 Nov 2025 21:38:13 +0100 Subject: [PATCH] linked to yakus --- src/button.ts | 16 +++- src/display/dp8.ts | 222 +++++++++++++++++++++++++++++++++++++++++++++ src/game.ts | 162 ++++++++++++++++++++++++++++++--- 3 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 src/display/dp8.ts diff --git a/src/button.ts b/src/button.ts index bb1ad60..fbb7352 100644 --- a/src/button.ts +++ b/src/button.ts @@ -36,6 +36,7 @@ const BUTTON_STYLES: Record = { kan: { text: "Kan", color: "#FFCC33", action: 3 }, ron: { text: "Ron", color: "#FF3060", action: 4 }, tsumo: { text: "Tsumo", color: "#FF3060", action: 5 }, + riichi: { text: "Riichi", color: "#66ccff", action: 6 }, back: { text: "Retour", color: "#FF9030", action: 0 } }; @@ -50,9 +51,10 @@ export function clickAction( pon: boolean, kan: boolean, ron: boolean, - tsumo: boolean + tsumo: boolean, + riichi: boolean ): number { - const activeButtons = getActiveButtons(chii, pon, kan, ron, tsumo); + const activeButtons = getActiveButtons(chii, pon, kan, ron, tsumo, riichi); if (activeButtons.length === 0) { return -1; @@ -91,14 +93,16 @@ export function drawButtons( pon: boolean, kan: boolean, ron: boolean, - tsumo: boolean + tsumo: boolean, + riichi: boolean ): void { const buttonFunctions: [boolean, ButtonRenderer][] = [ [chii, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.chii)], [pon, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.pon)], [kan, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.kan)], [ron, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.ron)], - [tsumo, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.tsumo)] + [tsumo, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.tsumo)], + [riichi, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.riichi)] ]; // Only show the pass button if at least one other button is active @@ -227,11 +231,13 @@ function getActiveButtons( pon: boolean, kan: boolean, ron: boolean, - tsumo: boolean + tsumo: boolean, + riichi: boolean ): number[] { const buttonConfigs: [boolean, number][] = [ [tsumo, BUTTON_STYLES.tsumo.action], [ron, BUTTON_STYLES.ron.action], + [riichi, BUTTON_STYLES.riichi.action], [kan, BUTTON_STYLES.kan.action], [pon, BUTTON_STYLES.pon.action], [chii, BUTTON_STYLES.chii.action] diff --git a/src/display/dp8.ts b/src/display/dp8.ts new file mode 100644 index 0000000..c5e231b --- /dev/null +++ b/src/display/dp8.ts @@ -0,0 +1,222 @@ +import { Deck } from "../deck"; +import { Hand } from "../hand"; +import { Game } from "../game"; + +function showPlayButton() { + const button = document.createElement('button'); + button.id = 'playButton'; + button.textContent = 'Jouer'; + button.style.position = 'absolute'; + button.style.left = `${1050/2}px`; + button.style.top = `${1050/2}px`; + button.style.transform = 'translate(-50%, -50%)'; + button.style.fontSize = '2rem'; + button.style.padding = '1em 2em'; + button.style.zIndex = '1000'; + + document.body.appendChild(button); + + button.addEventListener('click', async () => { + button.disabled = true; + button.textContent = 'Chargement...'; + await initDisplay(); + button.remove(); + }); +} + +class RiichiGameManagerDP8 { + private readonly CANVAS_ID: string = "myCanvas"; + private readonly BG_RECT = { x: 0, y: 0, w: 1050, h: 1050 }; + private readonly FPS: number = 60; + private readonly FRAME_INTERVAL: number = 1000 / this.FPS; + + private static instance: RiichiGameManagerDP8; + + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private staticCanvas: HTMLCanvasElement; + private staticCtx: CanvasRenderingContext2D; + + private mouse = { x: 0, y: 0 }; + private game: Game | null = null; + private decks: Array = []; + private hands: Array = []; + + private animationFrameId: number | null = null; + private lastFrameTime: number = 0; + private isInitialized: boolean = false; + + private cleanupCallbacks: Array<() => void> = []; + + private constructor() { + const canvas = document.getElementById(this.CANVAS_ID) as HTMLCanvasElement; + if (!canvas) { + throw new Error(`Canvas with ID '${this.CANVAS_ID}' not found`); + } + this.canvas = canvas; + this.canvas.width = this.BG_RECT.w; + this.canvas.height = this.BG_RECT.h; + + const ctx = canvas.getContext("2d", { alpha: false }); + if (!ctx) { + throw new Error("Unable to get 2D context"); + } + this.ctx = ctx; + + this.staticCanvas = document.createElement('canvas'); + this.staticCanvas.width = canvas.width; + this.staticCanvas.height = canvas.height; + + const staticCtx = this.staticCanvas.getContext("2d", { alpha: false }); + if (!staticCtx) { + throw new Error("Unable to get static context"); + } + this.staticCtx = staticCtx; + } + + public static getInstance(): RiichiGameManagerDP8 { + if (!RiichiGameManagerDP8.instance) { + RiichiGameManagerDP8.instance = new RiichiGameManagerDP8(); + } + return RiichiGameManagerDP8.instance; + } + + private drawFrame(): void { + if (this.game) { + this.game.draw(this.mouse); + + // If game finished and yakus enabled, draw the detected yakus on the left + if (this.game.isFinished()) { + const yakus = this.game.getLastYakus(); + const total = this.game.getLastTotalHan(); + if (yakus && yakus.length > 0) { + const ctx = this.staticCtx; + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(20, 60, 300, 24 + yakus.length * 24 + 30); + ctx.fillStyle = '#ffffff'; + ctx.font = '18px garamond'; + ctx.fillText('Yakus detected:', 30, 86); + for (let i = 0; i < yakus.length; i++) { + const y = 110 + i * 24; + ctx.fillText(`${yakus[i].name} (${yakus[i].han})`, 30, y); + } + ctx.fillText(`Total han: ${total}`, 30, 110 + yakus.length * 24 + 8); + ctx.restore(); + + // Blit to visible canvas + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.drawImage(this.staticCanvas, 0, 0); + } + } + } + } + + private startAnimationLoop(): void { + const animationLoop = (currentTime: number) => { + this.animationFrameId = requestAnimationFrame(animationLoop); + const deltaTime = currentTime - this.lastFrameTime; + if (deltaTime < this.FRAME_INTERVAL) return; + this.lastFrameTime = currentTime - (deltaTime % this.FRAME_INTERVAL); + this.drawFrame(); + }; + this.animationFrameId = requestAnimationFrame(animationLoop); + } + + private initEventListeners(): void { + const mouseMoveHandler = (e: MouseEvent) => { + const rect = this.canvas.getBoundingClientRect(); + this.mouse.x = e.clientX - rect.left; + this.mouse.y = e.clientY - rect.top; + }; + + const mouseDownHandler = (e: MouseEvent) => { + if (this.game) this.game.click(e as any); + }; + + this.canvas.addEventListener('mousemove', mouseMoveHandler); + this.canvas.addEventListener('mousedown', mouseDownHandler); + + this.cleanupCallbacks.push(() => { + this.canvas.removeEventListener('mousemove', mouseMoveHandler); + this.canvas.removeEventListener('mousedown', mouseDownHandler); + }); + } + + private async preloadDeck(deck: Deck): Promise { + await deck.preload(); + } + + private async preloadHand(hand: Hand): Promise { + await hand.preload(); + } + + public async initialize(): Promise { + if (this.isInitialized) return; + + try { + // Create the game with yakus enabled (dp8) + this.game = new Game( + this.ctx, + this.canvas, + this.staticCtx, + this.staticCanvas, + false, + 8, + Math.floor(Math.random() * 4), + true // enableYakus + ); + + await Promise.all([ + ...this.decks.map(d => this.preloadDeck(d)), + ...this.hands.map(h => this.preloadHand(h)), + this.game.preload() + ]); + + this.initEventListeners(); + this.startAnimationLoop(); + this.isInitialized = true; + (window as any).cleanup = this.cleanup.bind(this); + } catch (e) { + console.error('Initialization error dp8', e); + } + } + + public cleanup(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.cleanupCallbacks.forEach(cb => cb()); + this.cleanupCallbacks = []; + if (this.game) (this.game as any).cleanup?.(); + this.game = null; + this.decks.forEach(d => d.cleanup()); + this.hands.forEach(h => h.cleanup()); + this.decks = []; + this.hands = []; + this.isInitialized = false; + this.lastFrameTime = 0; + this.mouse = { x: 0, y: 0 }; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.staticCtx.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height); + } +} + +export function cleanup(): void { + RiichiGameManagerDP8.getInstance().cleanup(); +} + +export async function initDisplay(): Promise { + await RiichiGameManagerDP8.getInstance().initialize(); +} + +declare global { + interface Window { + cleanup: () => void; + } +} + +if (typeof window !== 'undefined') { + showPlayButton(); +} diff --git a/src/game.ts b/src/game.ts index 864b700..ec2da75 100644 --- a/src/game.ts +++ b/src/game.ts @@ -4,6 +4,7 @@ import { Tile } from "./tile"; import { Group } from "./group"; import { drawButtons, clickAction, drawChiis, clickChii } from "./button"; import { drawState } from "./state"; +import { yakus } from "./yakus/yaku"; export type MousePos = { x: number, y: number }; @@ -41,6 +42,7 @@ export class Game { private waitingTime = GAME_CONSTANTS.WAITING_TIME.DEFAULT; private selectedTile: number | undefined = undefined; private canCall: boolean = false; + private declaredRiichi: boolean[] = []; private hasPicked: boolean = false; private hasPlayed: boolean = false; private lastPlayed: number = Date.now(); @@ -58,6 +60,8 @@ export class Game { private readonly BG_RECT = GAME_CONSTANTS.BACKGROUND; private readonly rotations = [0, -GAME_CONSTANTS.PI / 2, -GAME_CONSTANTS.PI, GAME_CONSTANTS.PI / 2]; + // When enableYakus is true the game will evaluate yakus on any win and + // store the detected yakus for display. constructor( ctx: CanvasRenderingContext2D, cv: HTMLCanvasElement, @@ -65,7 +69,8 @@ export class Game { staticCv: HTMLCanvasElement, red: boolean = false, level: number = 0, - windPlayer: number = 0 + windPlayer: number = 0, + private enableYakus: boolean = false ) { this.ctx = ctx; this.cv = cv; @@ -80,6 +85,56 @@ export class Game { this.initializeGame(); } + // Last detected yakus for the most recent win (player index => list) + private lastYakus: Array<{ name: string; han: number }> = []; + private lastTotalHan: number = 0; + + /** + * Compute yakus for a given player and populate lastYakus/lastTotalHan. + * This uses the exported `yakus` table where each entry returns han (0 if not present). + */ + private computeYakusForPlayer(player: number): void { + this.lastYakus = []; + this.lastTotalHan = 0; + + try { + const hand = this.hands[player]; + const groups = this.groups[player] || []; + + for (const k of Object.keys(yakus)) { + // Call each yaku detector with (hand, groups, wind) + // Some detector functions expect the player's wind as an index + const fn = (yakus as any)[k]; + try { + const han = fn(hand, groups, this.windPlayer); + if (han && han > 0) { + this.lastYakus.push({ name: k, han }); + this.lastTotalHan += han; + } + } catch (e) { + // Ignore errors in any individual detector to avoid breaking the flow + console.warn(`Yaku ${k} threw:`, e); + } + } + } catch (e) { + console.warn("Failed to compute yakus:", e); + this.lastYakus = []; + this.lastTotalHan = 0; + } + } + + private resolveWin(player: number, resultCode: number, fromPlayer?: number): void { + if (this.enableYakus) { + this.computeYakusForPlayer(player); + } else { + this.lastYakus = []; + this.lastTotalHan = 0; + } + + this.end = true; + this.result = resultCode; + } + private initializeGame(): void { this.deck.shuffle(); @@ -93,6 +148,7 @@ export class Game { this.hands.push(this.deck.getRandomHand()); this.discards.push([]); this.groups.push([]); + this.declaredRiichi.push(false); } this.lastDiscard = undefined; @@ -137,8 +193,7 @@ export class Game { const rect = this.cv.getBoundingClientRect(); if (this.hasWin(0)) { - this.end = true; - this.result = 1; + this.resolveWin(0, 1); return; } @@ -164,6 +219,7 @@ export class Game { } } + const canRiichi = this.canDeclareRiichi(0); const action = clickAction( mp.x - rect.x, mp.y - rect.y, @@ -171,11 +227,16 @@ export class Game { this.canDoAPon(), false && this.level > 1, canRon, - canTsumo + canTsumo, + canRiichi ); if (this.canCall && action !== -1) { this.handleCallAction(action); + } else if (action === 6) { // Riichi action + if (canRiichi) { + this.declareRiichi(0); + } } else if (this.turn === 0 && this.selectedTile !== undefined) { this.handlePlayerDiscard(); } @@ -289,8 +350,7 @@ export class Game { private handleBotTurn(): void { if (this.hasWin(this.turn)) { - this.end = true; - this.result = 2; + this.resolveWin(this.turn, 2); return; } @@ -303,6 +363,7 @@ export class Game { this.hasPlayed = true; if (this.deck.length() <= 0) { + // Draw / no tiles left this.result = 0; this.end = true; return; @@ -364,8 +425,7 @@ export class Game { this.turn = 0; this.pick(0); if (this.hasWin(0)) { - this.end = true; - this.result = 1; + this.resolveWin(0, 1); } } else { this.turn++; @@ -456,8 +516,7 @@ export class Game { this.groups[p].push(new Group([t, tt[0], tt[1]], discardPlayer, p)); if (this.hasWin(p)) { - this.end = true; - this.result = p === 0 ? 1 : 2; + this.resolveWin(p, p === 0 ? 1 : 2); } this.updateWaitingTime(); @@ -518,6 +577,73 @@ export class Game { return this.hands[player].count(t.getFamily(), t.getValue()) >= 2; } + /** + * Check whether the given player's closed hand is in tenpai (waiting). + * We simulate adding any possible tile and check if it yields a winning hand. + */ + private isTenpai(player: number): boolean { + const h = this.hands[player]; + // quick guard: hand should not already be winning + if (h.toGroup() !== undefined) return false; + + // Try all possible tile types + // suits 1..3 values 1..9 + for (let fam = 1; fam <= 3; fam++) { + for (let val = 1; val <= 9; val++) { + const sim = new Hand(); + for (const t of h.getTiles()) { + sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed())); + } + sim.push(new Tile(fam, val, false)); + sim.sort(); + if (sim.toGroup() !== undefined) return true; + } + } + + // winds family 4 values 1..4 + for (let val = 1; val <= 4; val++) { + const sim = new Hand(); + for (const t of h.getTiles()) { + sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed())); + } + sim.push(new Tile(4, val, false)); + sim.sort(); + if (sim.toGroup() !== undefined) return true; + } + + // dragons family 5 values 1..3 + for (let val = 1; val <= 3; val++) { + const sim = new Hand(); + for (const t of h.getTiles()) { + sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed())); + } + sim.push(new Tile(5, val, false)); + sim.sort(); + if (sim.toGroup() !== undefined) return true; + } + + return false; + } + + private canDeclareRiichi(player: number): boolean { + // Only allow declaring Riichi for player 0 in the UI; general rule: it's the player's turn, + // they have just drawn (hasPicked true), haven't yet discarded (hasPlayed false), hand closed, + // in tenpai, and haven't already declared. + if (player !== 0) return false; + if (this.turn !== player) return false; + if (!this.hasPicked || this.hasPlayed) return false; + if (this.groups[player] && this.groups[player].length > 0) return false; // not closed + if (this.declaredRiichi[player]) return false; + return this.isTenpai(player); + } + + private declareRiichi(player: number): void { + this.declaredRiichi[player] = true; + // For now we only set the flag. In a full implementation we'd place the riichi stick, + // lock the hand, and handle the bet. We redraw to update UI. + this.drawGame(); + } + private pon(p: number, thief: number = 0): void { const t = this.discards[p].pop() as Tile; this.lastDiscard = undefined; @@ -530,8 +656,7 @@ export class Game { this.groups[thief].push(new Group([t, t2, t3], p, thief)); if (this.hasWin(thief)) { - this.end = true; - this.result = thief === 0 ? 1 : 2; + this.resolveWin(thief, thief === 0 ? 1 : 2); } this.updateWaitingTime(); @@ -584,13 +709,15 @@ export class Game { } } + const canRiichiLocal = this.canDeclareRiichi(0); drawButtons( this.staticCtx, this.canDoAChii().length > 0, this.canDoAPon(), false && this.level > 1, canRonLocal, - canTsumoLocal + canTsumoLocal, + canRiichiLocal ); } } @@ -774,4 +901,13 @@ export class Game { await this.deck.preload(); await Promise.all(this.hands.map(h => h.preload())); } + + // Expose last yakus for UI/display when enabled + public getLastYakus(): Array<{ name: string; han: number }> { + return this.lastYakus; + } + + public getLastTotalHan(): number { + return this.lastTotalHan; + } }