working riichi

This commit is contained in:
Didictateur 2026-01-02 22:22:02 +01:00
parent 0d2cb7919c
commit e6bd7c8af0

View file

@ -1,9 +1,9 @@
import { clickAction, clickChii, drawButtons, drawChiis } from "./button";
import { Deck } from "./deck";
import { Hand } from "./hand";
import { Tile } from "./tile";
import { Group } from "./group";
import { drawButtons, clickAction, drawChiis, clickChii } from "./button";
import { Hand } from "./hand";
import { drawState } from "./state";
import { Tile } from "./tile";
import { yakus } from "./yakus/yaku";
export type MousePos = { x: number, y: number };
@ -34,6 +34,7 @@ export class Game {
private discards: Array<Array<Tile>> = [];
private lastDiscard: number | undefined;
private groups: Array<Array<Group>> = [];
private riichiDiscardIndex: number[] = [];
// Game state
private level: number;
@ -54,6 +55,9 @@ export class Game {
private hasPlayed: boolean = false;
private lastPlayed: number = Date.now();
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 result: number = -1;
@ -95,16 +99,22 @@ export class Game {
private drawDynamic(ctx: CanvasRenderingContext2D): void {
// Redraw player's hand on top to show focused tile
const { HAND_SIZE } = GAME_CONSTANTS.DISPLAY;
this.hands[0].drawHand(
ctx,
2.5 * 75 * 0.75,
1000 - 150 * HAND_SIZE,
5 * HAND_SIZE,
0.75,
this.selectedTile,
false,
0
);
// In Riichi selection mode, draw tiles with graying for invalid discards
if (this.choosingRiichiDiscard) {
this.drawHandWithRiichiSelection(ctx, HAND_SIZE);
} else {
this.hands[0].drawHand(
ctx,
2.5 * 75 * 0.75,
1000 - 150 * HAND_SIZE,
5 * HAND_SIZE,
0.75,
this.selectedTile,
false,
0
);
}
// If a tile is selected, highlight matching discarded tiles
if (this.selectedTile !== undefined) {
@ -113,29 +123,47 @@ export class Game {
for (let p = 0; p < GAME_CONSTANTS.PLAYERS; p++) {
const d = this.discards[p];
if (!d || d.length === 0) continue;
const riichiIdx = this.riichiDiscardIndex[p];
ctx.save();
ctx.translate(525, 525);
ctx.rotate(this.rotations[p]);
for (let i = 0; i < d.length; i++) {
const tile = d[i];
if (!highlighted.isEqual(tile.getFamily(), tile.getValue())) continue;
let tx, ty;
if (i < 12) {
tx = -sizeDiscard * 475 / 2 + (i % 6) * 80 * sizeDiscard;
ty = sizeDiscard * (475 / 2 + 5) + Math.floor(i / 6) * 105 * sizeDiscard;
} else {
tx = -sizeDiscard * 475 / 2 + (i - 12) * 80 * sizeDiscard;
ty = sizeDiscard * (475 / 2 + 5) + 2 * 105 * sizeDiscard;
const isRiichiTile = (i === riichiIdx);
const row = i < 6 ? 0 : (i < 12 ? 1 : 2);
const col = i < 12 ? (i % 6) : (i - 12);
let tx = -sizeDiscard * 475 / 2 + col * 80 * 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();
}
}
// 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));
} else {
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);
// 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(
ctx,
this.canDoAChii().length > 0,
this.canDoAPon(),
canChiiDisplay,
canPonDisplay,
false && this.level > 1,
canRonLocal,
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)
private lastYakus: Array<{ name: string; han: number }> = [];
private lastTotalHan: number = 0;
@ -304,6 +405,7 @@ export class Game {
this.discards.push([]);
this.groups.push([]);
this.declaredRiichi.push(false);
this.riichiDiscardIndex.push(-1);
this.handVersion.push(0);
this.tenpaiCache.push(false);
this.tenpaiCacheVersion.push(-1);
@ -386,6 +488,9 @@ export class Game {
for (let i = 0; i < GAME_CONSTANTS.PLAYERS; i++) {
this.drawDiscard(i, undefined);
}
// Draw Riichi indicators (sticks and status)
this.drawRiichiIndicators();
this.staticDirty = false;
}
@ -418,6 +523,12 @@ export class Game {
return;
}
// Handle Riichi selection mode
if (this.choosingRiichiDiscard) {
this.handleRiichiSelection(mp, rect);
return;
}
if (this.chooseChii) {
this.handleChiiSelection(mp, rect);
return;
@ -443,11 +554,17 @@ export class Game {
}
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(
mp.x - rect.x,
mp.y - rect.y,
this.canDoAChii().length > 0,
this.canDoAPon(),
canChiiClick,
canPonClick,
false && this.level > 1,
canRon,
canTsumo,
@ -455,16 +572,111 @@ export class Game {
);
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);
} else if (action === 6) { // Riichi action
if (canRiichi) {
this.declareRiichi(0);
this.enterRiichiSelectionMode();
}
} else if (this.turn === 0 && this.selectedTile !== undefined) {
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 {
const allChii = this.getChii(0);
const selection = clickChii(
@ -501,6 +713,12 @@ export class Game {
private handlePlayerDiscard(): void {
this.discard(0, this.selectedTile as number);
// Level 8: Bots don't make calls
if (this.level === 8) {
this.advanceTurn();
return;
}
if (!this.checkPon()) {
const chiis = this.canDoAChii(1);
if (chiis.length > 0) {
@ -544,7 +762,23 @@ export class Game {
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) {
this.selectedTile = newSelected;
// 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) {
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
const n = Math.floor(this.hands[this.turn].length() * Math.random());
this.discard(this.turn, n);
@ -607,6 +848,12 @@ export class Game {
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
this.checkPon();
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.
* This builds a simulated hand for each candidate player to avoid mutating state.
@ -655,17 +911,20 @@ export class Game {
private advanceBotTurn(): void {
if (this.turn === 3) {
this.turn = 0;
this.turnCount++;
this.pick(0);
this.hasPicked = true;
this.hasPlayed = false;
if (this.hasWin(0) && (!this.enableYakus || this.handHasAnyYaku(this.hands[0], this.groups[0]))) {
this.resolveWin(0, 1);
}
} else {
this.turn++;
this.hasPicked = false;
this.hasPlayed = false;
}
this.updateWaitingTime();
this.hasPicked = false;
this.hasPlayed = false;
}
private pick(player: number): void {
@ -836,12 +1095,8 @@ export class Game {
}
const h = this.hands[player];
// quick guard: hand should not already be winning
if (h.toGroup() !== undefined) {
this.tenpaiCache[player] = false;
this.tenpaiCacheVersion[player] = this.handVersion[player];
return false;
}
// Note: For a 13-tile hand, we test if adding any tile makes it win
// No early guard needed - just test all possibilities
// Try all possible tile types
// suits 1..3 values 1..9
@ -911,23 +1166,20 @@ export class Game {
}
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.
// Riichi rules: player's turn, just drawn (hasPicked), hasn't discarded yet (hasPlayed false),
// hand is closed (no open groups), in tenpai, and hasn't already declared Riichi.
// Riichi itself IS a yaku, so no need to check for other yakus!
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.groups[player] && this.groups[player].length > 0) return false; // hand must be closed
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];
// 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) {
const tiles = hand.getTiles();
const removableLabels: string[] = [];
const perRemoval: Array<any> = [];
for (let i = 0; i < 14; i++) {
const sim = new Hand();
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.sort();
const isTen = this.handIsTenpai(sim);
const info: any = { removed: this.formatTile(tiles[i]), isTenpai: isTen };
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
}
if (this.handIsTenpai(sim)) {
return true; // At least one discard leaves us in tenpai
}
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) {
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;
return false;
}
if (!this.enableYakus) {
const res = 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;
// With 13 tiles, just check if we're in tenpai
return this.isTenpai(player);
}
// Helper: test tenpai for an arbitrary Hand instance (no caching)
private handIsTenpai(h: Hand): boolean {
// quick guard: hand should not already be winning
if (h.toGroup() !== undefined) return false;
// If hand is already a winning hand, it's technically "tenpai" on any tile it's waiting on
// 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
for (let fam = 1; fam <= 3; fam++) {
for (let val = 1; val <= 9; val++) {
@ -1199,13 +1360,6 @@ export class Game {
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 {
const t = this.discards[p].pop() as Tile;
this.lastDiscard = undefined;
@ -1384,28 +1538,58 @@ export class Game {
const x = -sizeDiscard * 475 / 2;
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
for (let i = 0; i < this.discards[p].length; i++) {
const tile = this.discards[p][i];
const isRiichiTile = (i === riichiIdx);
let tx, ty;
if (i < 12) {
tx = x + (i % 6) * 80 * sizeDiscard;
ty = y + Math.floor(i / 6) * 105 * sizeDiscard;
} else {
tx = x + (i - 12) * 80 * sizeDiscard;
ty = y + 2 * 105 * sizeDiscard;
// Calculate row and column
const row = i < 6 ? 0 : (i < 12 ? 1 : 2);
const col = i < 12 ? (i % 6) : (i - 12);
// Base position
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(
this.staticCtx,
tx,
ty,
sizeDiscard,
false,
0,
highlightedTile?.isEqual(tile.getFamily(), tile.getValue())
);
if (isRiichiTile) {
// Draw riichi tile sideways (90 degree rotation)
// Adjust position to account for rotation center
const adjustX = tx + 12 * sizeDiscard;
const adjustY = ty - 12 * sizeDiscard;
tile.drawTile(
this.staticCtx,
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
@ -1454,6 +1638,45 @@ export class Game {
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 {
if (this.result === -1) return;