working riichi
This commit is contained in:
parent
0d2cb7919c
commit
e6bd7c8af0
1 changed files with 393 additions and 170 deletions
563
src/game.ts
563
src/game.ts
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { clickAction, clickChii, drawButtons, drawChiis } from "./button";
|
||||||
import { Deck } from "./deck";
|
import { Deck } from "./deck";
|
||||||
import { Hand } from "./hand";
|
|
||||||
import { Tile } from "./tile";
|
|
||||||
import { Group } from "./group";
|
import { Group } from "./group";
|
||||||
import { drawButtons, clickAction, drawChiis, clickChii } from "./button";
|
import { Hand } from "./hand";
|
||||||
import { drawState } from "./state";
|
import { drawState } from "./state";
|
||||||
|
import { Tile } from "./tile";
|
||||||
import { yakus } from "./yakus/yaku";
|
import { yakus } from "./yakus/yaku";
|
||||||
|
|
||||||
export type MousePos = { x: number, y: number };
|
export type MousePos = { x: number, y: number };
|
||||||
|
|
@ -34,6 +34,7 @@ export class Game {
|
||||||
private discards: Array<Array<Tile>> = [];
|
private discards: Array<Array<Tile>> = [];
|
||||||
private lastDiscard: number | undefined;
|
private lastDiscard: number | undefined;
|
||||||
private groups: Array<Array<Group>> = [];
|
private groups: Array<Array<Group>> = [];
|
||||||
|
private riichiDiscardIndex: number[] = [];
|
||||||
|
|
||||||
// Game state
|
// Game state
|
||||||
private level: number;
|
private level: number;
|
||||||
|
|
@ -54,6 +55,9 @@ export class Game {
|
||||||
private hasPlayed: boolean = false;
|
private hasPlayed: boolean = false;
|
||||||
private lastPlayed: number = Date.now();
|
private lastPlayed: number = Date.now();
|
||||||
private chooseChii: boolean = false;
|
private chooseChii: boolean = false;
|
||||||
|
private choosingRiichiDiscard: boolean = false;
|
||||||
|
private validRiichiDiscards: boolean[] = []; // Which tiles can be discarded while keeping tenpai
|
||||||
|
private turnCount: number = 0; // Count of full rounds (each player played once)
|
||||||
private end: boolean = false;
|
private end: boolean = false;
|
||||||
private result: number = -1;
|
private result: number = -1;
|
||||||
|
|
||||||
|
|
@ -95,16 +99,22 @@ export class Game {
|
||||||
private drawDynamic(ctx: CanvasRenderingContext2D): void {
|
private drawDynamic(ctx: CanvasRenderingContext2D): void {
|
||||||
// Redraw player's hand on top to show focused tile
|
// Redraw player's hand on top to show focused tile
|
||||||
const { HAND_SIZE } = GAME_CONSTANTS.DISPLAY;
|
const { HAND_SIZE } = GAME_CONSTANTS.DISPLAY;
|
||||||
this.hands[0].drawHand(
|
|
||||||
ctx,
|
// In Riichi selection mode, draw tiles with graying for invalid discards
|
||||||
2.5 * 75 * 0.75,
|
if (this.choosingRiichiDiscard) {
|
||||||
1000 - 150 * HAND_SIZE,
|
this.drawHandWithRiichiSelection(ctx, HAND_SIZE);
|
||||||
5 * HAND_SIZE,
|
} else {
|
||||||
0.75,
|
this.hands[0].drawHand(
|
||||||
this.selectedTile,
|
ctx,
|
||||||
false,
|
2.5 * 75 * 0.75,
|
||||||
0
|
1000 - 150 * HAND_SIZE,
|
||||||
);
|
5 * HAND_SIZE,
|
||||||
|
0.75,
|
||||||
|
this.selectedTile,
|
||||||
|
false,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// If a tile is selected, highlight matching discarded tiles
|
// If a tile is selected, highlight matching discarded tiles
|
||||||
if (this.selectedTile !== undefined) {
|
if (this.selectedTile !== undefined) {
|
||||||
|
|
@ -113,29 +123,47 @@ export class Game {
|
||||||
for (let p = 0; p < GAME_CONSTANTS.PLAYERS; p++) {
|
for (let p = 0; p < GAME_CONSTANTS.PLAYERS; p++) {
|
||||||
const d = this.discards[p];
|
const d = this.discards[p];
|
||||||
if (!d || d.length === 0) continue;
|
if (!d || d.length === 0) continue;
|
||||||
|
const riichiIdx = this.riichiDiscardIndex[p];
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(525, 525);
|
ctx.translate(525, 525);
|
||||||
ctx.rotate(this.rotations[p]);
|
ctx.rotate(this.rotations[p]);
|
||||||
for (let i = 0; i < d.length; i++) {
|
for (let i = 0; i < d.length; i++) {
|
||||||
const tile = d[i];
|
const tile = d[i];
|
||||||
if (!highlighted.isEqual(tile.getFamily(), tile.getValue())) continue;
|
if (!highlighted.isEqual(tile.getFamily(), tile.getValue())) continue;
|
||||||
let tx, ty;
|
|
||||||
if (i < 12) {
|
const isRiichiTile = (i === riichiIdx);
|
||||||
tx = -sizeDiscard * 475 / 2 + (i % 6) * 80 * sizeDiscard;
|
const row = i < 6 ? 0 : (i < 12 ? 1 : 2);
|
||||||
ty = sizeDiscard * (475 / 2 + 5) + Math.floor(i / 6) * 105 * sizeDiscard;
|
const col = i < 12 ? (i % 6) : (i - 12);
|
||||||
} else {
|
|
||||||
tx = -sizeDiscard * 475 / 2 + (i - 12) * 80 * sizeDiscard;
|
let tx = -sizeDiscard * 475 / 2 + col * 80 * sizeDiscard;
|
||||||
ty = sizeDiscard * (475 / 2 + 5) + 2 * 105 * sizeDiscard;
|
let ty = sizeDiscard * (475 / 2 + 5) + row * 105 * sizeDiscard;
|
||||||
|
|
||||||
|
// Add extra offset for tiles after riichi tile in same row
|
||||||
|
if (riichiIdx >= 0 && i > riichiIdx) {
|
||||||
|
const riichiRow = riichiIdx < 6 ? 0 : (riichiIdx < 12 ? 1 : 2);
|
||||||
|
if (row === riichiRow) {
|
||||||
|
tx += 25 * sizeDiscard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRiichiTile) {
|
||||||
|
// Draw riichi tile sideways (90 degree rotation)
|
||||||
|
const adjustX = tx + 12 * sizeDiscard;
|
||||||
|
const adjustY = ty - 12 * sizeDiscard;
|
||||||
|
tile.drawTile(ctx, adjustX, adjustY, sizeDiscard, false, GAME_CONSTANTS.PI / 2, true);
|
||||||
|
} else {
|
||||||
|
tile.drawTile(ctx, tx, ty, sizeDiscard, false, 0, true);
|
||||||
}
|
}
|
||||||
// Draw highlighted version on top
|
|
||||||
tile.drawTile(ctx, tx, ty, sizeDiscard, false, 0, true);
|
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw call buttons / chiis on top
|
// Draw call buttons / chiis on top
|
||||||
if (this.chooseChii) {
|
if (this.choosingRiichiDiscard) {
|
||||||
|
// Draw cancel button and Riichi instruction
|
||||||
|
this.drawRiichiSelectionUI(ctx);
|
||||||
|
} else if (this.chooseChii) {
|
||||||
drawChiis(ctx, this.getChii(0));
|
drawChiis(ctx, this.getChii(0));
|
||||||
} else {
|
} else {
|
||||||
const canTsumoLocal = this.hasPicked && this.hasWin(0) && (!this.enableYakus || this.handHasAnyYaku(this.hands[0], this.groups[0]));
|
const canTsumoLocal = this.hasPicked && this.hasWin(0) && (!this.enableYakus || this.handHasAnyYaku(this.hands[0], this.groups[0]));
|
||||||
|
|
@ -164,10 +192,16 @@ export class Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
const canRiichiLocal = this.canDeclareRiichi(0);
|
const canRiichiLocal = this.canDeclareRiichi(0);
|
||||||
|
|
||||||
|
// When in Riichi, player cannot call chii/pon but can still Ron/Tsumo
|
||||||
|
const inRiichi = this.declaredRiichi[0];
|
||||||
|
const canChiiDisplay = !inRiichi && this.canDoAChii().length > 0;
|
||||||
|
const canPonDisplay = !inRiichi && this.canDoAPon();
|
||||||
|
|
||||||
drawButtons(
|
drawButtons(
|
||||||
ctx,
|
ctx,
|
||||||
this.canDoAChii().length > 0,
|
canChiiDisplay,
|
||||||
this.canDoAPon(),
|
canPonDisplay,
|
||||||
false && this.level > 1,
|
false && this.level > 1,
|
||||||
canRonLocal,
|
canRonLocal,
|
||||||
canTsumoLocal,
|
canTsumoLocal,
|
||||||
|
|
@ -176,6 +210,73 @@ export class Game {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw player's hand with grayed out invalid Riichi discards
|
||||||
|
*/
|
||||||
|
private drawHandWithRiichiSelection(ctx: CanvasRenderingContext2D, handSize: number): void {
|
||||||
|
const tiles = this.hands[0].getTiles();
|
||||||
|
const x = 2.5 * 75 * 0.75;
|
||||||
|
const y = 1000 - 150 * handSize;
|
||||||
|
const tileOffset = (75 + 5 * handSize) * 0.75;
|
||||||
|
|
||||||
|
for (let i = 0; i < tiles.length; i++) {
|
||||||
|
const isLastAndIsolated = (i === tiles.length - 1 && this.hands[0].isolate) ? 10 : 0;
|
||||||
|
let tileX = x + i * tileOffset + isLastAndIsolated * 0.75;
|
||||||
|
let tileY = y;
|
||||||
|
|
||||||
|
// Lift tile if selected
|
||||||
|
if (i === this.selectedTile) {
|
||||||
|
tileY -= 25 * 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gray out tiles that would break tenpai
|
||||||
|
const isValidDiscard = this.validRiichiDiscards[i] || false;
|
||||||
|
tiles[i].drawTile(
|
||||||
|
ctx,
|
||||||
|
tileX,
|
||||||
|
tileY,
|
||||||
|
0.75,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
!isValidDiscard // gray if not valid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw Riichi selection UI (cancel button and instruction text)
|
||||||
|
*/
|
||||||
|
private drawRiichiSelectionUI(ctx: CanvasRenderingContext2D): void {
|
||||||
|
// Draw instruction text
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = "#66ccff";
|
||||||
|
ctx.font = "bold 24px garamond";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("Choisissez une tuile à défausser pour déclarer Riichi", 525, 820);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw cancel button
|
||||||
|
ctx.fillStyle = "#FF9030";
|
||||||
|
ctx.beginPath();
|
||||||
|
const btnX = 800, btnY = 835, btnW = 110, btnH = 50, r = 8;
|
||||||
|
ctx.moveTo(btnX + r, btnY);
|
||||||
|
ctx.lineTo(btnX + btnW - r, btnY);
|
||||||
|
ctx.quadraticCurveTo(btnX + btnW, btnY, btnX + btnW, btnY + r);
|
||||||
|
ctx.lineTo(btnX + btnW, btnY + btnH - r);
|
||||||
|
ctx.quadraticCurveTo(btnX + btnW, btnY + btnH, btnX + btnW - r, btnY + btnH);
|
||||||
|
ctx.lineTo(btnX + r, btnY + btnH);
|
||||||
|
ctx.quadraticCurveTo(btnX, btnY + btnH, btnX, btnY + btnH - r);
|
||||||
|
ctx.lineTo(btnX, btnY + r);
|
||||||
|
ctx.quadraticCurveTo(btnX, btnY, btnX + r, btnY);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = "#606060";
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "black";
|
||||||
|
ctx.font = "30px garamond";
|
||||||
|
ctx.fillText("Annuler", btnX + 15, btnY + 35);
|
||||||
|
}
|
||||||
|
|
||||||
// Last detected yakus for the most recent win (player index => list)
|
// Last detected yakus for the most recent win (player index => list)
|
||||||
private lastYakus: Array<{ name: string; han: number }> = [];
|
private lastYakus: Array<{ name: string; han: number }> = [];
|
||||||
private lastTotalHan: number = 0;
|
private lastTotalHan: number = 0;
|
||||||
|
|
@ -304,6 +405,7 @@ export class Game {
|
||||||
this.discards.push([]);
|
this.discards.push([]);
|
||||||
this.groups.push([]);
|
this.groups.push([]);
|
||||||
this.declaredRiichi.push(false);
|
this.declaredRiichi.push(false);
|
||||||
|
this.riichiDiscardIndex.push(-1);
|
||||||
this.handVersion.push(0);
|
this.handVersion.push(0);
|
||||||
this.tenpaiCache.push(false);
|
this.tenpaiCache.push(false);
|
||||||
this.tenpaiCacheVersion.push(-1);
|
this.tenpaiCacheVersion.push(-1);
|
||||||
|
|
@ -386,6 +488,9 @@ export class Game {
|
||||||
for (let i = 0; i < GAME_CONSTANTS.PLAYERS; i++) {
|
for (let i = 0; i < GAME_CONSTANTS.PLAYERS; i++) {
|
||||||
this.drawDiscard(i, undefined);
|
this.drawDiscard(i, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw Riichi indicators (sticks and status)
|
||||||
|
this.drawRiichiIndicators();
|
||||||
|
|
||||||
this.staticDirty = false;
|
this.staticDirty = false;
|
||||||
}
|
}
|
||||||
|
|
@ -418,6 +523,12 @@ export class Game {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Riichi selection mode
|
||||||
|
if (this.choosingRiichiDiscard) {
|
||||||
|
this.handleRiichiSelection(mp, rect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.chooseChii) {
|
if (this.chooseChii) {
|
||||||
this.handleChiiSelection(mp, rect);
|
this.handleChiiSelection(mp, rect);
|
||||||
return;
|
return;
|
||||||
|
|
@ -443,11 +554,17 @@ export class Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
const canRiichi = this.canDeclareRiichi(0);
|
const canRiichi = this.canDeclareRiichi(0);
|
||||||
|
|
||||||
|
// When in Riichi, player cannot call chii/pon but can still Ron
|
||||||
|
const inRiichi = this.declaredRiichi[0];
|
||||||
|
const canChiiClick = !inRiichi && this.canDoAChii().length > 0;
|
||||||
|
const canPonClick = !inRiichi && this.canDoAPon();
|
||||||
|
|
||||||
const action = clickAction(
|
const action = clickAction(
|
||||||
mp.x - rect.x,
|
mp.x - rect.x,
|
||||||
mp.y - rect.y,
|
mp.y - rect.y,
|
||||||
this.canDoAChii().length > 0,
|
canChiiClick,
|
||||||
this.canDoAPon(),
|
canPonClick,
|
||||||
false && this.level > 1,
|
false && this.level > 1,
|
||||||
canRon,
|
canRon,
|
||||||
canTsumo,
|
canTsumo,
|
||||||
|
|
@ -455,16 +572,111 @@ export class Game {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.canCall && action !== -1) {
|
if (this.canCall && action !== -1) {
|
||||||
|
// In Riichi, only allow pass (0), ron (4), or tsumo (5)
|
||||||
|
if (inRiichi && action !== 0 && action !== 4 && action !== 5) {
|
||||||
|
return; // Ignore chii/pon/kan actions when in Riichi
|
||||||
|
}
|
||||||
this.handleCallAction(action);
|
this.handleCallAction(action);
|
||||||
} else if (action === 6) { // Riichi action
|
} else if (action === 6) { // Riichi action
|
||||||
if (canRiichi) {
|
if (canRiichi) {
|
||||||
this.declareRiichi(0);
|
this.enterRiichiSelectionMode();
|
||||||
}
|
}
|
||||||
} else if (this.turn === 0 && this.selectedTile !== undefined) {
|
} else if (this.turn === 0 && this.selectedTile !== undefined) {
|
||||||
this.handlePlayerDiscard();
|
this.handlePlayerDiscard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle clicks during Riichi tile selection mode
|
||||||
|
*/
|
||||||
|
private handleRiichiSelection(mp: MousePos, rect: DOMRect): void {
|
||||||
|
const x = mp.x - rect.x;
|
||||||
|
const y = mp.y - rect.y;
|
||||||
|
|
||||||
|
// Check if cancel button was clicked (button at x=800, y=835, w=110, h=50)
|
||||||
|
if (x >= 800 && x <= 910 && y >= 835 && y <= 885) {
|
||||||
|
this.cancelRiichiSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a valid tile was clicked
|
||||||
|
if (this.selectedTile !== undefined && this.validRiichiDiscards[this.selectedTile]) {
|
||||||
|
this.confirmRiichiDiscard(this.selectedTile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter Riichi selection mode - calculate valid discards
|
||||||
|
*/
|
||||||
|
private enterRiichiSelectionMode(): void {
|
||||||
|
this.choosingRiichiDiscard = true;
|
||||||
|
this.validRiichiDiscards = this.calculateValidRiichiDiscards();
|
||||||
|
this.selectedTile = undefined;
|
||||||
|
this.staticDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel Riichi selection and return to normal state
|
||||||
|
*/
|
||||||
|
private cancelRiichiSelection(): void {
|
||||||
|
this.choosingRiichiDiscard = false;
|
||||||
|
this.validRiichiDiscards = [];
|
||||||
|
this.selectedTile = undefined;
|
||||||
|
this.staticDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm Riichi declaration with selected tile
|
||||||
|
*/
|
||||||
|
private confirmRiichiDiscard(tileIndex: number): void {
|
||||||
|
this.choosingRiichiDiscard = false;
|
||||||
|
this.validRiichiDiscards = [];
|
||||||
|
|
||||||
|
// Set Riichi state
|
||||||
|
this.declaredRiichi[0] = true;
|
||||||
|
this.riichiDiscardIndex[0] = this.discards[0].length;
|
||||||
|
|
||||||
|
// Discard the selected tile
|
||||||
|
this.discard(0, tileIndex);
|
||||||
|
|
||||||
|
// Continue game flow
|
||||||
|
if (!this.checkPon()) {
|
||||||
|
const chiis = this.canDoAChii(1);
|
||||||
|
if (chiis.length > 0) {
|
||||||
|
const i = Math.floor(Math.random() * chiis.length);
|
||||||
|
this.chii(chiis[i], 1);
|
||||||
|
} else {
|
||||||
|
this.advanceTurn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.staticDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate which tiles can be discarded while maintaining tenpai
|
||||||
|
*/
|
||||||
|
private calculateValidRiichiDiscards(): boolean[] {
|
||||||
|
const tiles = this.hands[0].getTiles();
|
||||||
|
const valid: boolean[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < tiles.length; i++) {
|
||||||
|
// Create a simulated hand without tile i
|
||||||
|
const sim = new Hand();
|
||||||
|
for (let j = 0; j < tiles.length; j++) {
|
||||||
|
if (j === i) continue;
|
||||||
|
const t = tiles[j];
|
||||||
|
sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
||||||
|
}
|
||||||
|
sim.sort();
|
||||||
|
|
||||||
|
// Check if this hand is in tenpai
|
||||||
|
valid[i] = this.handIsTenpai(sim);
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
private handleChiiSelection(mp: MousePos, rect: DOMRect): void {
|
private handleChiiSelection(mp: MousePos, rect: DOMRect): void {
|
||||||
const allChii = this.getChii(0);
|
const allChii = this.getChii(0);
|
||||||
const selection = clickChii(
|
const selection = clickChii(
|
||||||
|
|
@ -501,6 +713,12 @@ export class Game {
|
||||||
private handlePlayerDiscard(): void {
|
private handlePlayerDiscard(): void {
|
||||||
this.discard(0, this.selectedTile as number);
|
this.discard(0, this.selectedTile as number);
|
||||||
|
|
||||||
|
// Level 8: Bots don't make calls
|
||||||
|
if (this.level === 8) {
|
||||||
|
this.advanceTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.checkPon()) {
|
if (!this.checkPon()) {
|
||||||
const chiis = this.canDoAChii(1);
|
const chiis = this.canDoAChii(1);
|
||||||
if (chiis.length > 0) {
|
if (chiis.length > 0) {
|
||||||
|
|
@ -544,7 +762,23 @@ export class Game {
|
||||||
mouseY <= y + 100 * sizeHand
|
mouseY <= y + 100 * sizeHand
|
||||||
);
|
);
|
||||||
|
|
||||||
const newSelected = inBounds ? tileIndex : undefined;
|
let newSelected = inBounds ? tileIndex : undefined;
|
||||||
|
|
||||||
|
// When choosing Riichi discard, only allow selecting valid tiles
|
||||||
|
if (this.choosingRiichiDiscard && newSelected !== undefined) {
|
||||||
|
if (!this.validRiichiDiscards[newSelected]) {
|
||||||
|
newSelected = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When already in Riichi, player can only select and discard the last drawn tile
|
||||||
|
else if (this.declaredRiichi[0] && newSelected !== undefined) {
|
||||||
|
const lastTileIndex = this.hands[0].length() - 1;
|
||||||
|
// Only allow selecting the isolated (last drawn) tile
|
||||||
|
if (newSelected !== lastTileIndex) {
|
||||||
|
newSelected = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (newSelected !== this.selectedTile) {
|
if (newSelected !== this.selectedTile) {
|
||||||
this.selectedTile = newSelected;
|
this.selectedTile = newSelected;
|
||||||
// Force static buffer rebuild so the static hand skips drawing the
|
// Force static buffer rebuild so the static hand skips drawing the
|
||||||
|
|
@ -587,6 +821,13 @@ export class Game {
|
||||||
if (Date.now() - this.lastPlayed > this.waitingTime) {
|
if (Date.now() - this.lastPlayed > this.waitingTime) {
|
||||||
this.lastPlayed = Date.now();
|
this.lastPlayed = Date.now();
|
||||||
|
|
||||||
|
// Level 8: Bots might declare fake Riichi after 9 turns
|
||||||
|
if (this.level === 8 && this.turnCount >= 9 && !this.declaredRiichi[this.turn]) {
|
||||||
|
if (Math.random() < 1/9) {
|
||||||
|
this.declareBotRiichi(this.turn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Choose random tile to discard
|
// Choose random tile to discard
|
||||||
const n = Math.floor(this.hands[this.turn].length() * Math.random());
|
const n = Math.floor(this.hands[this.turn].length() * Math.random());
|
||||||
this.discard(this.turn, n);
|
this.discard(this.turn, n);
|
||||||
|
|
@ -607,6 +848,12 @@ export class Game {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Level 8: Bots don't make calls (pon/chii)
|
||||||
|
if (this.level === 8) {
|
||||||
|
this.canCall = this.canDoAChii().length > 0 || this.canDoAPon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise proceed with normal automatic pon/chii resolution
|
// Otherwise proceed with normal automatic pon/chii resolution
|
||||||
this.checkPon();
|
this.checkPon();
|
||||||
this.checkForChii();
|
this.checkForChii();
|
||||||
|
|
@ -615,6 +862,15 @@ export class Game {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bot declares Riichi (can be fake - just for visual effect in level 8)
|
||||||
|
*/
|
||||||
|
private declareBotRiichi(player: number): void {
|
||||||
|
this.declaredRiichi[player] = true;
|
||||||
|
this.riichiDiscardIndex[player] = this.discards[player].length;
|
||||||
|
this.staticDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if any other player can Ron on the last discard performed by discardPlayer.
|
* Check if any other player can Ron on the last discard performed by discardPlayer.
|
||||||
* This builds a simulated hand for each candidate player to avoid mutating state.
|
* This builds a simulated hand for each candidate player to avoid mutating state.
|
||||||
|
|
@ -655,17 +911,20 @@ export class Game {
|
||||||
private advanceBotTurn(): void {
|
private advanceBotTurn(): void {
|
||||||
if (this.turn === 3) {
|
if (this.turn === 3) {
|
||||||
this.turn = 0;
|
this.turn = 0;
|
||||||
|
this.turnCount++;
|
||||||
this.pick(0);
|
this.pick(0);
|
||||||
|
this.hasPicked = true;
|
||||||
|
this.hasPlayed = false;
|
||||||
if (this.hasWin(0) && (!this.enableYakus || this.handHasAnyYaku(this.hands[0], this.groups[0]))) {
|
if (this.hasWin(0) && (!this.enableYakus || this.handHasAnyYaku(this.hands[0], this.groups[0]))) {
|
||||||
this.resolveWin(0, 1);
|
this.resolveWin(0, 1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.turn++;
|
this.turn++;
|
||||||
|
this.hasPicked = false;
|
||||||
|
this.hasPlayed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateWaitingTime();
|
this.updateWaitingTime();
|
||||||
this.hasPicked = false;
|
|
||||||
this.hasPlayed = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private pick(player: number): void {
|
private pick(player: number): void {
|
||||||
|
|
@ -836,12 +1095,8 @@ export class Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
const h = this.hands[player];
|
const h = this.hands[player];
|
||||||
// quick guard: hand should not already be winning
|
// Note: For a 13-tile hand, we test if adding any tile makes it win
|
||||||
if (h.toGroup() !== undefined) {
|
// No early guard needed - just test all possibilities
|
||||||
this.tenpaiCache[player] = false;
|
|
||||||
this.tenpaiCacheVersion[player] = this.handVersion[player];
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try all possible tile types
|
// Try all possible tile types
|
||||||
// suits 1..3 values 1..9
|
// suits 1..3 values 1..9
|
||||||
|
|
@ -911,23 +1166,20 @@ export class Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
private canDeclareRiichi(player: number): boolean {
|
private canDeclareRiichi(player: number): boolean {
|
||||||
// Only allow declaring Riichi for player 0 in the UI; general rule: it's the player's turn,
|
// Riichi rules: player's turn, just drawn (hasPicked), hasn't discarded yet (hasPlayed false),
|
||||||
// they have just drawn (hasPicked true), haven't yet discarded (hasPlayed false), hand closed,
|
// hand is closed (no open groups), in tenpai, and hasn't already declared Riichi.
|
||||||
// in tenpai, and haven't already declared.
|
// Riichi itself IS a yaku, so no need to check for other yakus!
|
||||||
if (player !== 0) return false;
|
if (player !== 0) return false;
|
||||||
if (this.turn !== player) return false;
|
if (this.turn !== player) return false;
|
||||||
if (!this.hasPicked || this.hasPlayed) return false;
|
if (!this.hasPicked || this.hasPlayed) return false;
|
||||||
if (this.groups[player] && this.groups[player].length > 0) return false; // not closed
|
if (this.groups[player] && this.groups[player].length > 0) return false; // hand must be closed
|
||||||
if (this.declaredRiichi[player]) return false;
|
if (this.declaredRiichi[player]) return false;
|
||||||
// When yakus are enabled, allow Riichi either when in tenpai normally
|
|
||||||
// or when at least one winning tile would produce a yaku.
|
|
||||||
const hand = this.hands[player];
|
const hand = this.hands[player];
|
||||||
// If the player currently has 14 tiles (just drawn), check any possible
|
|
||||||
// 13-tile variant (remove one tile) for tenpai rules.
|
// With 14 tiles (just drawn), check if discarding any tile leaves us in tenpai
|
||||||
if (hand.length && hand.length() === 14) {
|
if (hand.length && hand.length() === 14) {
|
||||||
const tiles = hand.getTiles();
|
const tiles = hand.getTiles();
|
||||||
const removableLabels: string[] = [];
|
|
||||||
const perRemoval: Array<any> = [];
|
|
||||||
for (let i = 0; i < 14; i++) {
|
for (let i = 0; i < 14; i++) {
|
||||||
const sim = new Hand();
|
const sim = new Hand();
|
||||||
for (let j = 0; j < tiles.length; j++) {
|
for (let j = 0; j < tiles.length; j++) {
|
||||||
|
|
@ -936,114 +1188,23 @@ export class Game {
|
||||||
sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
||||||
}
|
}
|
||||||
sim.sort();
|
sim.sort();
|
||||||
const isTen = this.handIsTenpai(sim);
|
if (this.handIsTenpai(sim)) {
|
||||||
const info: any = { removed: this.formatTile(tiles[i]), isTenpai: isTen };
|
return true; // At least one discard leaves us in tenpai
|
||||||
let foundWinningTiles: string[] = [];
|
|
||||||
let yakusPerWin: Record<string, Array<{ name: string; han: number }>> = {};
|
|
||||||
|
|
||||||
// If yakus are enabled, search winning tiles and list yakus
|
|
||||||
if (this.enableYakus) {
|
|
||||||
// iterate all possible tile types
|
|
||||||
for (let fam = 1; fam <= 3; fam++) {
|
|
||||||
for (let val = 1; val <= 9; val++) {
|
|
||||||
const sim2 = new Hand();
|
|
||||||
for (const t of sim.getTiles()) sim2.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
|
||||||
sim2.push(new Tile(fam, val, false));
|
|
||||||
sim2.sort();
|
|
||||||
if (sim2.toGroup() !== undefined) {
|
|
||||||
const simGroups = sim2.toGroup() as Array<Group> | undefined;
|
|
||||||
const combined = this.groups[player].concat(simGroups ? simGroups : []);
|
|
||||||
const yakuList = this.getYakusForHand(sim2, combined);
|
|
||||||
const tileLabel = this.formatTile(new Tile(fam, val, false));
|
|
||||||
foundWinningTiles.push(tileLabel);
|
|
||||||
yakusPerWin[tileLabel] = yakuList;
|
|
||||||
}
|
|
||||||
// try red five variant
|
|
||||||
if (val === 5) {
|
|
||||||
const sim2r = new Hand();
|
|
||||||
for (const t of sim.getTiles()) sim2r.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
|
||||||
sim2r.push(new Tile(fam, val, true));
|
|
||||||
sim2r.sort();
|
|
||||||
if (sim2r.toGroup() !== undefined) {
|
|
||||||
const simGroups = sim2r.toGroup() as Array<Group> | undefined;
|
|
||||||
const combined = this.groups[player].concat(simGroups ? simGroups : []);
|
|
||||||
const yakuList = this.getYakusForHand(sim2r, combined);
|
|
||||||
const tileLabel = this.formatTile(new Tile(fam, val, true));
|
|
||||||
foundWinningTiles.push(tileLabel);
|
|
||||||
yakusPerWin[tileLabel] = yakuList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let val = 1; val <= 4; val++) {
|
|
||||||
const sim2 = new Hand();
|
|
||||||
for (const t of sim.getTiles()) sim2.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
|
||||||
sim2.push(new Tile(4, val, false));
|
|
||||||
sim2.sort();
|
|
||||||
if (sim2.toGroup() !== undefined) {
|
|
||||||
const simGroups = sim2.toGroup() as Array<Group> | undefined;
|
|
||||||
const combined = this.groups[player].concat(simGroups ? simGroups : []);
|
|
||||||
const yakuList = this.getYakusForHand(sim2, combined);
|
|
||||||
const tileLabel = this.formatTile(new Tile(4, val, false));
|
|
||||||
foundWinningTiles.push(tileLabel);
|
|
||||||
yakusPerWin[tileLabel] = yakuList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let val = 1; val <= 3; val++) {
|
|
||||||
const sim2 = new Hand();
|
|
||||||
for (const t of sim.getTiles()) sim2.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
|
||||||
sim2.push(new Tile(5, val, false));
|
|
||||||
sim2.sort();
|
|
||||||
if (sim2.toGroup() !== undefined) {
|
|
||||||
const simGroups = sim2.toGroup() as Array<Group> | undefined;
|
|
||||||
const combined = this.groups[player].concat(simGroups ? simGroups : []);
|
|
||||||
const yakuList = this.getYakusForHand(sim2, combined);
|
|
||||||
const tileLabel = this.formatTile(new Tile(5, val, false));
|
|
||||||
foundWinningTiles.push(tileLabel);
|
|
||||||
yakusPerWin[tileLabel] = yakuList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If yakus not enabled but sim is tenpai, we still consider it removable
|
|
||||||
if (!isTen) {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info.winningTiles = foundWinningTiles;
|
|
||||||
info.yakusPerWin = yakusPerWin;
|
|
||||||
perRemoval.push(info);
|
|
||||||
if (!this.enableYakus && isTen) removableLabels.push(this.formatTile(tiles[i]));
|
|
||||||
if (this.enableYakus && foundWinningTiles.length > 0) removableLabels.push(this.formatTile(tiles[i]));
|
|
||||||
}
|
}
|
||||||
if (player === 0) {
|
return false;
|
||||||
if (removableLabels.length > 0) {
|
|
||||||
console.debug("canDeclareRiichi (14->13) removable tiles:", removableLabels);
|
|
||||||
} else {
|
|
||||||
console.debug("canDeclareRiichi (14->13) no removable tile makes tenpai", { player });
|
|
||||||
}
|
|
||||||
// Detailed per-removal dump for debugging
|
|
||||||
console.debug("canDeclareRiichi (14->13) details:", perRemoval);
|
|
||||||
}
|
|
||||||
return removableLabels.length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.enableYakus) {
|
// With 13 tiles, just check if we're in tenpai
|
||||||
const res = this.isTenpai(player);
|
return this.isTenpai(player);
|
||||||
if (player === 0) console.debug("canDeclareRiichi (yakus disabled) ->", { player, turn: this.turn, hasPicked: this.hasPicked, hasPlayed: this.hasPlayed, groups: this.groups[player]?.length, declaredRiichi: this.declaredRiichi[player], isTenpai: res });
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
const tenpai = this.isTenpai(player);
|
|
||||||
const tenpaiY = this.isTenpaiWithYaku(player);
|
|
||||||
if (player === 0) console.debug("canDeclareRiichi ->", { player, turn: this.turn, hasPicked: this.hasPicked, hasPlayed: this.hasPlayed, groups: this.groups[player]?.length, declaredRiichi: this.declaredRiichi[player], isTenpai: tenpai, isTenpaiWithYaku: tenpaiY });
|
|
||||||
return tenpai || tenpaiY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: test tenpai for an arbitrary Hand instance (no caching)
|
// Helper: test tenpai for an arbitrary Hand instance (no caching)
|
||||||
private handIsTenpai(h: Hand): boolean {
|
private handIsTenpai(h: Hand): boolean {
|
||||||
// quick guard: hand should not already be winning
|
// If hand is already a winning hand, it's technically "tenpai" on any tile it's waiting on
|
||||||
if (h.toGroup() !== undefined) return false;
|
// But for 13 tiles we want to find if adding ANY tile makes it win
|
||||||
|
// Note: a 13-tile hand should not already be winning (needs 14 for complete hand)
|
||||||
|
|
||||||
// suits 1..3 values 1..9
|
// suits 1..3 values 1..9
|
||||||
for (let fam = 1; fam <= 3; fam++) {
|
for (let fam = 1; fam <= 3; fam++) {
|
||||||
for (let val = 1; val <= 9; val++) {
|
for (let val = 1; val <= 9; val++) {
|
||||||
|
|
@ -1199,13 +1360,6 @@ export class Game {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
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. Mark static buffer dirty so UI updates next frame.
|
|
||||||
this.staticDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private pon(p: number, thief: number = 0): void {
|
private pon(p: number, thief: number = 0): void {
|
||||||
const t = this.discards[p].pop() as Tile;
|
const t = this.discards[p].pop() as Tile;
|
||||||
this.lastDiscard = undefined;
|
this.lastDiscard = undefined;
|
||||||
|
|
@ -1384,28 +1538,58 @@ export class Game {
|
||||||
const x = -sizeDiscard * 475 / 2;
|
const x = -sizeDiscard * 475 / 2;
|
||||||
const y = sizeDiscard * (475 / 2 + 5);
|
const y = sizeDiscard * (475 / 2 + 5);
|
||||||
|
|
||||||
|
// Track extra offset caused by sideways riichi tiles
|
||||||
|
let extraOffset = 0;
|
||||||
|
const riichiIdx = this.riichiDiscardIndex[p];
|
||||||
|
|
||||||
// Draw all discarded tiles
|
// Draw all discarded tiles
|
||||||
for (let i = 0; i < this.discards[p].length; i++) {
|
for (let i = 0; i < this.discards[p].length; i++) {
|
||||||
const tile = this.discards[p][i];
|
const tile = this.discards[p][i];
|
||||||
|
const isRiichiTile = (i === riichiIdx);
|
||||||
let tx, ty;
|
let tx, ty;
|
||||||
|
|
||||||
if (i < 12) {
|
// Calculate row and column
|
||||||
tx = x + (i % 6) * 80 * sizeDiscard;
|
const row = i < 6 ? 0 : (i < 12 ? 1 : 2);
|
||||||
ty = y + Math.floor(i / 6) * 105 * sizeDiscard;
|
const col = i < 12 ? (i % 6) : (i - 12);
|
||||||
} else {
|
|
||||||
tx = x + (i - 12) * 80 * sizeDiscard;
|
// Base position
|
||||||
ty = y + 2 * 105 * sizeDiscard;
|
tx = x + col * 80 * sizeDiscard;
|
||||||
|
ty = y + row * 105 * sizeDiscard;
|
||||||
|
|
||||||
|
// Add extra offset for tiles after riichi tile in same row
|
||||||
|
if (riichiIdx >= 0 && i > riichiIdx) {
|
||||||
|
const riichiRow = riichiIdx < 6 ? 0 : (riichiIdx < 12 ? 1 : 2);
|
||||||
|
if (row === riichiRow) {
|
||||||
|
// Sideways tile is wider: add offset (height - width difference)
|
||||||
|
tx += 25 * sizeDiscard;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tile.drawTile(
|
if (isRiichiTile) {
|
||||||
this.staticCtx,
|
// Draw riichi tile sideways (90 degree rotation)
|
||||||
tx,
|
// Adjust position to account for rotation center
|
||||||
ty,
|
const adjustX = tx + 12 * sizeDiscard;
|
||||||
sizeDiscard,
|
const adjustY = ty - 12 * sizeDiscard;
|
||||||
false,
|
tile.drawTile(
|
||||||
0,
|
this.staticCtx,
|
||||||
highlightedTile?.isEqual(tile.getFamily(), tile.getValue())
|
adjustX,
|
||||||
);
|
adjustY,
|
||||||
|
sizeDiscard,
|
||||||
|
false,
|
||||||
|
GAME_CONSTANTS.PI / 2, // 90 degrees
|
||||||
|
highlightedTile?.isEqual(tile.getFamily(), tile.getValue())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tile.drawTile(
|
||||||
|
this.staticCtx,
|
||||||
|
tx,
|
||||||
|
ty,
|
||||||
|
sizeDiscard,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
highlightedTile?.isEqual(tile.getFamily(), tile.getValue())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw indicator for last discard
|
// Draw indicator for last discard
|
||||||
|
|
@ -1454,6 +1638,45 @@ export class Game {
|
||||||
this.staticCtx.fillText(remainingTiles.toString(), x, 537);
|
this.staticCtx.fillText(remainingTiles.toString(), x, 537);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw Riichi indicators: sticks for players who have declared Riichi
|
||||||
|
* and a visual indicator showing locked hand status
|
||||||
|
*/
|
||||||
|
private drawRiichiIndicators(): void {
|
||||||
|
const ctx = this.staticCtx;
|
||||||
|
const center = 525;
|
||||||
|
|
||||||
|
for (let p = 0; p < GAME_CONSTANTS.PLAYERS; p++) {
|
||||||
|
if (!this.declaredRiichi[p]) continue;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(center, center);
|
||||||
|
ctx.rotate(this.rotations[p]);
|
||||||
|
|
||||||
|
// Draw riichi stick (1000 point stick) - horizontal bar near discard area
|
||||||
|
const stickY = 120;
|
||||||
|
const stickWidth = 80;
|
||||||
|
const stickHeight = 8;
|
||||||
|
|
||||||
|
// White stick background
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.strokeStyle = "#333333";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-stickWidth / 2, stickY, stickWidth, stickHeight, 3);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Red dot in center (1000 point stick marking)
|
||||||
|
ctx.fillStyle = "#cc0000";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, stickY + stickHeight / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private drawResult(): void {
|
private drawResult(): void {
|
||||||
if (this.result === -1) return;
|
if (this.result === -1) return;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue