linked to yakus

This commit is contained in:
Didictateur 2025-11-18 21:38:13 +01:00
parent 6d9ff97834
commit 09dd8a70b2
3 changed files with 382 additions and 18 deletions

View file

@ -36,6 +36,7 @@ const BUTTON_STYLES: Record<string, ButtonConfig> = {
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]

222
src/display/dp8.ts Normal file
View file

@ -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<Deck> = [];
private hands: Array<Hand> = [];
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<void> {
await deck.preload();
}
private async preloadHand(hand: Hand): Promise<void> {
await hand.preload();
}
public async initialize(): Promise<void> {
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<void> {
await RiichiGameManagerDP8.getInstance().initialize();
}
declare global {
interface Window {
cleanup: () => void;
}
}
if (typeof window !== 'undefined') {
showPlayButton();
}

View file

@ -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;
}
}