From e6bd7c8af03dab549621915e4eb9a842710f5674 Mon Sep 17 00:00:00 2001 From: Didictateur Date: Fri, 2 Jan 2026 22:22:02 +0100 Subject: [PATCH] working riichi --- src/game.ts | 563 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 393 insertions(+), 170 deletions(-) diff --git a/src/game.ts b/src/game.ts index 19e9d4b..3ab6f3e 100644 --- a/src/game.ts +++ b/src/game.ts @@ -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> = []; private lastDiscard: number | undefined; private groups: Array> = []; + 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 = []; 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> = {}; - - // 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 | 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 | 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 | 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 | 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;