basic project

This commit is contained in:
Didictateur 2026-02-18 14:41:05 +01:00
parent e6bd7c8af0
commit 1aee19e63b
63 changed files with 158 additions and 8785 deletions

24
backend/src/api.py Normal file
View file

@ -0,0 +1,24 @@
from flask import Flask, jsonify, request
from flask_cors import CORS
from game.tile import *
app = Flask(__name__)
CORS(app)
tiles = tiles = Tile.generateAll()
# ============= GET ================
@app.route('api/tiles', methods=['GET'])
def get_all_tiles():
"""Récupère toutes les tuiles"""
return jsonify(tiles)
@app.route('api/tiles/<int:tile_id>', method=['GET'])
def get_tile(tile_id):
if tile_id < 0 or tile_id >= len(tiles):
return jsonify({'error': 'Tuile non trouvée'}), 404
return jsonify({'tile': tiles[tile_id]})
# ============== POST ================

View file

15
backend/src/game/error.py Normal file
View file

@ -0,0 +1,15 @@
class TileError(Exception):
"""Exception de base pour les erreurs de tuiles"""
pass
class InvalidFamilyError(TileError):
"""Famille de tuile invalide"""
pass
class InvalidValueError(TileError):
"""Valeur de tuile invalide"""
pass
class GameError(Exception):
"""Exception de base pour les erreurs de jeu"""
pass

0
backend/src/game/hand.py Normal file
View file

100
backend/src/game/tile.py Normal file
View file

@ -0,0 +1,100 @@
from error import *
from value import *
class Tile:
def __init__(self, family, value, id):
self.family = family
self.value = value
self.id = id
self.hidden = False
self.img = None
self.img_back = "Back.svg"
self.img_front = "Front.svg"
self.img_shadow = "Gray.svg"
if self.family == Family.MAN:
if self.value in range(1, 10):
self.img_symbol = f"Man{self.value}.svg"
else:
raise InvalidValueError(f"Invalide value: {self.value}")
elif self.family == Family.PIN:
if self.value in range(1, 10):
self.img_symbol = f"Pin{self.value}.svg"
else:
raise InvalidValueError(f"Invalide value: {self.value}")
elif self.family == Family.SOU:
if self.value in range(1, 10):
self.img_symbol = f"Sou{self.value}.svg"
else:
raise InvalidValueError(f"Invalide value: {self.value}")
elif self.family == Family.WIND:
if self.value == Wind.EAST:
self.img_symbol = "Ton.svg"
elif self.value == Wind.SOUTH:
self.img_symbol = "Nan.svg"
elif self.value == Wind.WEST:
self.img_symbol = "Shaa.svg"
elif self.value == Wind.NORTH:
self.img_symbol = "Pei.svg"
else:
raise InvalidValueError(f"Invalide value: {self.value}")
elif self.family == Family.DRAGON:
if self.value == Dragon.RED:
self.img_symbol = "Chun.svg"
elif self.value == Dragon.GREEN:
self.img_symbol = "Hatsu.svg"
elif self.value == Dragon.WHITE:
self.img_symbol = "Haku.svg"
else:
raise InvalidValueError(f"Invalide value: {self.value}")
else:
raise InvalidFamilyError(f"Invalide family: {self.family}")
def get_img(self):
if self.img == None:
self.img = f"""
<g class="tile tile-{self.id}" data-tile-id="{self.id}">
<image href="svg/{self.img_shadow}" x="2" y="2" width="60" height="80" class="shadow"/>
<image href="svg/{self.img_front}" x="0" y="0" width="60" height="80" class="front"/>
<image href="svg/{self.img_symbol}" x="12" y="20" width="36" height="40" class="symbol"/>
</g>
"""
return self.img
def __eq__(self, other: Tile):
return self.value == other.value and self.family == other.family
def __lt__(self, other: Tile):
return (self.family < other.family) or (self.family == other.family and self.value < other.value)
@staticmethod
def generateAll():
tiles = []
id = 0
for _ in range(4):
# man
for i in range(1, 10):
tiles.append(Tile(Family.MAN, i, id))
id += 1
# pin
for i in range(1, 10):
tiles.append(Tile(Family.PIN, i, id))
id += 1
# sou
for i in range(1, 10):
tiles.append(Tile(Family.SOU, i, id))
id += 1
# wind
for wind in [Wind.EAST, Wind.SOUTH, Wind.WEST, Wind.NORTH]:
tiles.append(Tile(Family.WIND, wind, id))
id += 1
# dragon
for dragon in [Dragon.RED, Dragon.GREEN, Dragon.WHITE]:
tiles.append(Tile(Family.DRAGON, dragon, id))
id += 1
return tiles

19
backend/src/game/value.py Normal file
View file

@ -0,0 +1,19 @@
from enum import Enum
class Family(Enum):
MAN = 0
PIN = 1
SOU = 2
WIND = 3
DRAGON = 4
class Wind(Enum):
EAST : 0
SOUTH : 1
WEST : 2
NORTH : 3
class Dragon(Enum):
RED : 0
GREEN : 1
WHITE : 2

0
backend/src/game/wall.py Normal file
View file

View file

@ -1,262 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Tutoriel interactif de Mahjong Riichi - Apprenez à jouer au Mahjong japonais">
<meta name="theme-color" content="#1a7f4b">
<title>Mahjong Riichi - Tutoriel Interactif</title>
<!-- Preload fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@500;600;700&display=swap" rel="stylesheet">
<!-- Main stylesheet -->
<link rel="stylesheet" href="src/styles/main.css">
</head>
<body>
<!-- Navigation -->
<nav class="navbar" role="navigation" aria-label="Navigation principale">
<ul class="navbar__container">
<!-- GitHub Link -->
<li class="navbar__item">
<a href="https://github.com/Didictateur/Riichi"
class="navbar__link"
target="_blank"
rel="noopener noreferrer"
aria-label="Voir le code source sur GitHub">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
</svg>
GitHub
</a>
</li>
<!-- Introduction -->
<li class="navbar__item">
<a href="#" class="navbar__link" onclick="loadScript('dp0.js'); return false;">
Introduction
</a>
</li>
<!-- Riichi Mahjong -->
<li class="navbar__item">
<a href="#" class="navbar__link navbar__link--dropdown">
Riichi Mahjong
</a>
<div class="dropdown" role="menu">
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp1.js'); return false;" role="menuitem">
Chap 1: Présentation des tuiles
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp2.js'); return false;" role="menuitem">
Chap 2: La main
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp3.js'); return false;" role="menuitem">
Chap 3: Les groupes
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp4.js'); return false;" role="menuitem">
Chap 4: Avoir une main valide en jeu
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp5.js'); return false;" role="menuitem">
Chap 5: Annoncer la victoire
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp6.js'); return false;" role="menuitem">
Chap 6: Le vent du joueur
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp7.js'); return false;" role="menuitem">
Chap 7: Les yakus
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp8.js'); return false;" role="menuitem">
Chap 8: Le riichi
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp9.js'); return false;" role="menuitem">
Chap 9: Le furiten
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp10.js'); return false;" role="menuitem">
Chap 10: Première partie complète
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp11.js'); return false;" role="menuitem">
Chap 11: Score: les Hans
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp12.js'); return false;" role="menuitem">
Chap 12: Score: les doras
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp13.js'); return false;" role="menuitem">
Chap 13: Score: les Fus
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp14.js'); return false;" role="menuitem">
Chap 14: Score: le calcul des points
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp15.js'); return false;" role="menuitem">
Chap 15: Partie avec les scores
</a>
</div>
</li>
<!-- Partie réelle -->
<li class="navbar__item">
<a href="#" class="navbar__link navbar__link--dropdown">
Partie réelle
</a>
<div class="dropdown" role="menu">
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp16.js'); return false;" role="menuitem">
Chap 1: Placement des joueurs
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp17.js'); return false;" role="menuitem">
Chap 2: Construction des murs
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp18.js'); return false;" role="menuitem">
Chap 3: Désigner la pioche
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp19.js'); return false;" role="menuitem">
Chap 4: Le mur mort
</a>
</div>
</li>
<!-- Riichi à trois -->
<li class="navbar__item">
<a href="#" class="navbar__link" onclick="loadScript('dp20.js'); return false;">
Riichi à 3
</a>
</li>
<!-- Yaku trainer -->
<li class="navbar__item">
<a href="#" class="navbar__link navbar__link--dropdown">
Yaku Trainer
</a>
<div class="dropdown" role="menu">
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp21.js'); return false;" role="menuitem">
Entraînement Yaku 1
</a>
</div>
</li>
<!-- MCR -->
<li class="navbar__item">
<a href="#" class="navbar__link navbar__link--dropdown">
MCR
</a>
<div class="dropdown" role="menu">
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp22.js'); return false;" role="menuitem">
Chapitre 1
</a>
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp23.js'); return false;" role="menuitem">
Chapitre 2
</a>
</div>
</li>
</ul>
</nav>
<!-- Main Content -->
<main class="main-container">
<div id="canvasContainer" class="canvas-wrapper animate-fadeIn">
<canvas id="myCanvas" width="1000" height="1000"></canvas>
</div>
<div id="anotherCanvasContainer" class="canvas-wrapper animate-fadeIn">
<canvas id="myTextCanvas" width="1000" height="1000"></canvas>
</div>
</main>
<!-- Application Script -->
<script type="module">
// State management
const AppState = {
currentScript: null,
lastLoadedScript: 'dp0.js',
lastTextMode: true
};
// Utility functions
function removePlayButton() {
const btn = document.getElementById("playButton");
if (btn) btn.remove();
}
function generateTimestamp() {
return Date.now();
}
function cleanupPreviousState() {
if (window.cleanup) {
window.cleanup();
}
removePlayButton();
}
// Load text content
function loadText(scriptName, nb) {
cleanupPreviousState();
window.txtNumber = nb;
const container = document.getElementById("anotherCanvasContainer");
container.innerHTML = `
<canvas id="myTextCanvas" width="1000" height="1000"></canvas>
`;
const script = document.createElement("script");
script.type = "module";
script.src = `build/${scriptName}?t=${generateTimestamp()}`;
document.body.appendChild(script);
AppState.currentScript = script;
}
// Load main script
function loadScript(scriptName, txt = true) {
cleanupPreviousState();
const mainContainer = document.querySelector('.main-container');
const container = document.getElementById("canvasContainer");
const textContainer = document.getElementById("anotherCanvasContainer");
// Mode introduction (dp0) : canvas plus grand
const isIntro = scriptName === 'dp0.js';
if (isIntro) {
mainContainer.classList.add('intro-mode');
container.innerHTML = `
<canvas id="myCanvas" width="1000" height="1000"></canvas>
`;
} else {
mainContainer.classList.remove('intro-mode');
container.innerHTML = `
<canvas id="myCanvas" width="1000" height="1000"></canvas>
`;
}
const script = document.createElement("script");
script.type = "module";
script.src = `build/${scriptName}?t=${generateTimestamp()}`;
document.body.appendChild(script);
AppState.lastLoadedScript = scriptName;
AppState.lastTextMode = txt;
AppState.currentScript = script;
if (txt) {
const number = scriptName.substring(2, scriptName.length - 3);
loadText("txt.js", number);
}
}
// Keyboard navigation
document.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
loadScript(AppState.lastLoadedScript, AppState.lastTextMode);
}
});
// Expose to global scope for onclick handlers
window.loadScript = loadScript;
window.loadText = loadText;
// Initialize
loadScript('dp0.js');
</script>
</body>
</html>

View file

@ -1,317 +0,0 @@
import { Tile } from "./tile";
/**
* Type definition for button rendering functions
*/
type ButtonRenderer = (
ctx: CanvasRenderingContext2D,
x: number,
y: number
) => void;
/**
* Button configuration interface
*/
interface ButtonConfig {
text: string;
color: string;
action: number;
}
// Button constants
const BUTTON_RADIUS = 8;
const BUTTON_WIDTH = 110;
const BUTTON_HEIGHT = 50;
const BUTTON_SPACING = 120;
const BUTTON_AREA_Y_MIN = 838;
const BUTTON_AREA_Y_MAX = 888;
const BUTTON_MARGIN = 10;
const BASE_X_POSITION = 850;
// Button style configurations
const BUTTON_STYLES: Record<string, ButtonConfig> = {
pass: { text: "Ignorer", color: "#FF9030", action: 0 },
chii: { text: "Chii", color: "#FFCC33", action: 1 },
pon: { text: "Pon", color: "#FFCC33", action: 2 },
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 }
};
/**
* Determines which button was clicked based on coordinates
* @returns The action value of the clicked button or -1 if no button was clicked
*/
export function clickAction(
x: number,
y: number,
chii: boolean,
pon: boolean,
kan: boolean,
ron: boolean,
tsumo: boolean,
riichi: boolean
): number {
const activeButtons = getActiveButtons(chii, pon, kan, ron, tsumo, riichi);
if (activeButtons.length === 0) {
return -1;
}
// Calculate starting X position based on number of buttons
const xmin = 960 - activeButtons.length * BUTTON_SPACING;
// Check if Y coordinate is within button area
const isYInButtonArea = BUTTON_AREA_Y_MIN < y && y < BUTTON_AREA_Y_MAX;
if (!isYInButtonArea) {
return -1;
}
// Calculate which button was clicked
const buttonIndex = Math.floor((x - xmin) / BUTTON_SPACING);
const xOffset = (x - xmin) - BUTTON_SPACING * buttonIndex;
if (
buttonIndex >= 0 &&
buttonIndex < activeButtons.length &&
xOffset > BUTTON_MARGIN
) {
return activeButtons[buttonIndex];
}
return -1;
}
/**
* Draw all active buttons on the canvas
*/
export function drawButtons(
ctx: CanvasRenderingContext2D,
chii: boolean,
pon: boolean,
kan: boolean,
ron: 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)],
[riichi, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.riichi)]
];
// Only show the pass button if at least one other button is active
const hasActiveButtons = buttonFunctions.some(([isActive]) => isActive);
if (hasActiveButtons) {
buttonFunctions.unshift([true, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.pass)]);
} else {
return; // No buttons to draw
}
// Draw active buttons
let positionOffset = 0;
for (const [isActive, renderFunc] of buttonFunctions) {
if (isActive) {
renderFunc(
ctx,
BASE_X_POSITION - positionOffset * BUTTON_SPACING,
835
);
positionOffset++;
}
}
}
/**
* Determines which Chi option was clicked
* @returns The value of the clicked Chi option or -1 if no option was clicked
*/
export function clickChii(
x: number,
y: number,
chiis: Array<Array<Tile>>
): number {
if (chiis.length === 0) {
return -1;
}
// Calculate starting X position based on number of options
const xmin = 960 - (chiis.length + 1) * BUTTON_SPACING;
// Check if Y coordinate is within button area
const isYInButtonArea = BUTTON_AREA_Y_MIN < y && y < BUTTON_AREA_Y_MAX;
if (!isYInButtonArea) {
return -1;
}
// Calculate which option was clicked
const optionIndex = Math.floor((x - xmin) / BUTTON_SPACING);
const xOffset = (x - xmin) - BUTTON_SPACING * optionIndex;
if (
optionIndex >= 0 &&
optionIndex < (chiis.length + 1) &&
xOffset > BUTTON_MARGIN
) {
// Return 0 for "back" button or the value of the selected Chi option
return optionIndex === chiis.length ? 0 : chiis[optionIndex][0].getValue();
}
return -1;
}
/**
* Draw Chi options on the canvas
*/
export function drawChiis(
ctx: CanvasRenderingContext2D,
chiis: Array<Array<Tile>>
): void {
// Create a copy to avoid modifying the original array
const chiiOptions = [...chiis].reverse();
// Draw "back" button
renderButton(ctx, BASE_X_POSITION, 835, BUTTON_STYLES.back);
// Draw Chi options
let positionOffset = 1;
for (const tiles of chiiOptions) {
drawOneChii(
ctx,
BASE_X_POSITION - positionOffset * BUTTON_SPACING,
835,
tiles
);
positionOffset++;
}
}
/**
* Draw a single Chi option
*/
function drawOneChii(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
tiles: Array<Tile>
): void {
const tileOffset = 32;
const tileStartX = x + 7;
const tileStartY = y + 5;
// Draw the button background
drawButtonShape(ctx, x, y, BUTTON_RADIUS, BUTTON_WIDTH, BUTTON_HEIGHT, BUTTON_STYLES.chii.color);
// Draw the tiles
for (let i = 0; i < tiles.length; i++) {
tiles[i].drawTile(
ctx,
tileStartX + tileOffset * i,
tileStartY,
0.4,
false,
0,
false,
false
);
}
}
/**
* Get the list of active button action values
*/
function getActiveButtons(
chii: boolean,
pon: boolean,
kan: boolean,
ron: 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]
];
const activeButtons = buttonConfigs
.filter(([isActive]) => isActive)
.map(([, action]) => action);
// Add pass button if any other buttons are active
if (activeButtons.length > 0) {
activeButtons.push(BUTTON_STYLES.pass.action);
}
return activeButtons;
}
/**
* Render a button with text
*/
function renderButton(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
config: ButtonConfig
): void {
drawButtonShape(ctx, x, y, BUTTON_RADIUS, BUTTON_WIDTH, BUTTON_HEIGHT, config.color);
// Add text to the button
ctx.fillStyle = "black";
ctx.font = "30px garamond";
// Center text based on its length
const textXPosition = x + BUTTON_WIDTH * (0.25 - config.text.length * 0.025);
const textYPosition = y + BUTTON_HEIGHT/2 * 1.3;
ctx.fillText(config.text, textXPosition, textYPosition);
}
/**
* Draw a rounded rectangle button shape
*/
function drawButtonShape(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
width: number,
height: number,
color: string
): void {
ctx.fillStyle = color;
ctx.beginPath();
// Top right corner
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
// Bottom right corner
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
// Bottom left corner
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
// Top left corner
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.fill();
// Add border
ctx.fillStyle = "#606060";
ctx.stroke();
}

View file

@ -1,216 +0,0 @@
/**
* Shared constants for the Riichi Mahjong Tutorial
*/
// ============ Canvas Constants ============
export const CANVAS = {
WIDTH: 1000,
HEIGHT: 1000,
GAME_AREA: {
X: 0,
Y: 0,
WIDTH: 1050,
HEIGHT: 1050,
},
} as const;
// ============ Colors ============
export const COLORS = {
// Main theme
PRIMARY: '#1a7f4b',
PRIMARY_LIGHT: '#22a861',
PRIMARY_DARK: '#0d5c34',
// Game board
TABLE_GREEN: '#007730',
TABLE_DARK: '#005520',
// Tiles
TILE_FRONT: '#FFFFFF',
TILE_BACK: '#4A90D9',
TILE_SHADOW: 'rgba(0, 0, 0, 0.3)',
TILE_HIGHLIGHT: 'rgba(255, 215, 0, 0.5)',
// Buttons
BUTTON_PASS: '#FF9030',
BUTTON_CALL: '#FFCC33',
BUTTON_WIN: '#FF3060',
BUTTON_RIICHI: '#66ccff',
// Text
TEXT_LIGHT: '#FFFFFF',
TEXT_DARK: '#1A1A1A',
TEXT_MUTED: '#6B7280',
// Status
SUCCESS: '#22C55E',
WARNING: '#F59E0B',
ERROR: '#EF4444',
INFO: '#3B82F6',
} as const;
// ============ Tile Constants ============
export const TILE = {
// Base dimensions (at scale 1)
WIDTH: 75,
HEIGHT: 100,
// Families
FAMILY: {
MAN: 1,
PIN: 2,
SOU: 3,
WIND: 4,
DRAGON: 5,
},
// Wind values
WIND: {
EAST: 1,
SOUTH: 2,
WEST: 3,
NORTH: 4,
},
// Dragon values
DRAGON: {
RED: 1, // Chun
GREEN: 2, // Hatsu
WHITE: 3, // Haku
},
// Image paths
IMAGE_PATH: 'img/Regular/',
// Families for numbered tiles
SUIT_FAMILIES: [1, 2, 3] as const,
HONOR_FAMILIES: [4, 5] as const,
} as const;
// ============ Game Constants ============
export const GAME = {
// Players
PLAYER_COUNT: 4,
// Hand sizes
HAND_SIZE: 13,
WINNING_HAND_SIZE: 14,
// Wall
WALL_SIZE: 136,
DEAD_WALL_SIZE: 14,
DORA_INDICATOR_COUNT: 5,
// Timing (in ms)
TIMING: {
MIN_WAIT: 500,
DEFAULT_WAIT: 700,
MAX_WAIT: 2000,
ANIMATION_DURATION: 300,
},
// Display sizes (scale factors)
DISPLAY: {
HAND: 0.7,
HIDDEN_HAND: 0.6,
DISCARD: 0.6,
GROUP: 0.6,
},
} as const;
// ============ Math Constants ============
export const MATH = {
PI: Math.PI,
HALF_PI: Math.PI / 2,
TWO_PI: Math.PI * 2,
DEG_TO_RAD: Math.PI / 180,
RAD_TO_DEG: 180 / Math.PI,
} as const;
// ============ Animation Constants ============
export const ANIMATION = {
FPS: 60,
FRAME_INTERVAL: 1000 / 60,
TIME_STEP: 1 / 60,
} as const;
// ============ Physics Constants ============
export const PHYSICS = {
GRAVITY: 100,
FALLING_TILE: {
INITIAL_Y: -150,
INITIAL_VELOCITY: 50,
SPAWN_CHANCE: 0.75,
ROTATION_FACTOR: Math.PI,
MOMENTUM_RANGE: 1,
MAX_Y: 1100,
},
} as const;
// ============ Button Constants ============
export const BUTTON = {
RADIUS: 8,
WIDTH: 110,
HEIGHT: 50,
SPACING: 120,
MARGIN: 10,
AREA: {
Y_MIN: 838,
Y_MAX: 888,
},
BASE_X: 850,
} as const;
// ============ Score Constants ============
export const SCORE = {
// Base points for different limits
MANGAN: 2000,
HANEMAN: 3000,
BAIMAN: 4000,
SANBAIMAN: 6000,
YAKUMAN: 8000,
// Han thresholds
HAN_LIMITS: {
MANGAN: 5,
HANEMAN: 6,
BAIMAN: 8,
SANBAIMAN: 11,
YAKUMAN: 13,
},
// Starting score
STARTING_POINTS: 25000,
TARGET_POINTS: 30000,
// Riichi
RIICHI_COST: 1000,
} as const;
// ============ Wind Rotations ============
export const ROTATIONS = [
0, // East (bottom)
-MATH.HALF_PI, // South (right)
-MATH.PI, // West (top)
MATH.HALF_PI, // North (left)
] as const;
// ============ Keyboard Keys ============
export const KEYS = {
ENTER: 'Enter',
ESCAPE: 'Escape',
SPACE: ' ',
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
ARROW_UP: 'ArrowUp',
ARROW_DOWN: 'ArrowDown',
} as const;
// ============ CSS Class Names ============
export const CSS_CLASSES = {
CANVAS_WRAPPER: 'canvas-wrapper',
ANIMATE_FADE_IN: 'animate-fadeIn',
ANIMATE_SLIDE_IN: 'animate-slideIn',
HIDDEN: 'hidden',
VISIBLE: 'visible',
} as const;

View file

@ -1,293 +0,0 @@
import { Tile } from "./tile";
import { Hand } from "./hand";
type TileKey = `${number}-${number}`;
export class Deck {
private tiles: Tile[];
private tileIndexMap: Map<TileKey, number[]>; // Fast lookup for find() and count()
public constructor(allowRed: boolean = false) {
this.tiles = [];
this.tileIndexMap = new Map();
this.initTiles(allowRed);
}
/**
* Displays all tile families on the canvas
*/
public displayFamilies(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
xOffset: number = 0,
yOffset: number = 0
): void {
let posX = x;
let posY = y;
// Define tile layouts for each family
const familyLayouts = [
{ family: 1, start: 1, end: 9 }, // First suit
{ family: 2, start: 1, end: 9 }, // Second suit
{ family: 3, start: 1, end: 9 }, // Third suit
{ family: 4, start: 1, end: 4 }, // Winds
{ family: 5, start: 1, end: 3 } // Dragons
];
for (const layout of familyLayouts) {
for (let j = layout.start; j <= layout.end; j++) {
const tile = this.find(layout.family, j);
if (tile) {
tile.drawTile(ctx, posX, posY, size, false, 0, false);
posX += (75 + xOffset) * size;
}
}
posX = x;
posY += (100 + yOffset) * size;
}
}
/**
* Returns the number of tiles in the deck
*/
public length(): number {
return this.tiles.length;
}
/**
* Removes and returns the last tile from the deck
* @throws Error if the deck is empty
*/
public pop(): Tile {
if (this.tiles.length === 0) {
throw new Error("Cannot pop from an empty deck");
}
const tile = this.tiles.pop()!;
// Update the index map
const key = this.getTileKey(tile.getFamily(), tile.getValue());
const indices = this.tileIndexMap.get(key);
if (indices && indices.length > 0) {
indices.pop(); // Remove the last index
if (indices.length === 0) {
this.tileIndexMap.delete(key);
} else {
this.tileIndexMap.set(key, indices);
}
}
return tile;
}
/**
* Adds a tile to the deck
*/
public push(tile: Tile): void {
const index = this.tiles.length;
this.tiles.push(tile);
// Update the index map
const key = this.getTileKey(tile.getFamily(), tile.getValue());
const indices = this.tileIndexMap.get(key) || [];
indices.push(index);
this.tileIndexMap.set(key, indices);
}
/**
* Finds and removes a specific tile from the deck
*/
public find(family: number, value: number): Tile | undefined {
const key = this.getTileKey(family, value);
const indices = this.tileIndexMap.get(key);
if (!indices || indices.length === 0) {
return undefined;
}
// Get the first occurrence
const index = indices[0];
if (index >= this.tiles.length) {
// Handle potential out-of-sync errors
this.rebuildIndexMap();
return this.find(family, value);
}
// Swap with the first element for efficient removal
[this.tiles[index], this.tiles[0]] = [this.tiles[0], this.tiles[index]];
// Update indices in the map
this.updateIndicesAfterSwap(0, index);
// Remove and return the tile
const tile = this.tiles.shift();
// Update all indices after shift
this.decrementIndicesAfterShift();
return tile;
}
/**
* Counts the number of tiles with specific family and value
*/
public count(family: number, value: number): number {
const key = this.getTileKey(family, value);
const indices = this.tileIndexMap.get(key);
return indices ? indices.length : 0;
}
/**
* Shuffles the deck using Fisher-Yates algorithm
*/
public shuffle(): void {
// Fisher-Yates shuffle algorithm
for (let i = this.tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.tiles[i], this.tiles[j]] = [this.tiles[j], this.tiles[i]];
}
// Rebuild the index map after shuffling
this.rebuildIndexMap();
}
/**
* Creates a random hand from the deck
*/
public getRandomHand(): Hand {
const hand = new Hand();
this.shuffle();
// Handle case where deck doesn't have enough tiles
if (this.tiles.length < 13) {
throw new Error("Not enough tiles in deck to create a hand");
}
for (let i = 0; i < 13; i++) {
hand.push(this.pop());
}
return hand;
}
/**
* Cleans up resources used by tiles and empties the deck
*/
public cleanup(): void {
this.tiles.forEach(tile => tile.cleanup());
this.tiles = [];
this.tileIndexMap.clear();
}
/**
* Preloads all tile images
*/
public async preload(): Promise<void> {
const preloadPromises = this.tiles.map(tile => tile.preloadImg());
await Promise.all(preloadPromises);
}
/**
* Creates a unique key for each tile type
*/
private getTileKey(family: number, value: number): TileKey {
return `${family}-${value}`;
}
/**
* Updates the index map after swapping two tiles
*/
private updateIndicesAfterSwap(index1: number, index2: number): void {
if (index1 === index2) return;
const tile1 = this.tiles[index1];
const tile2 = this.tiles[index2];
const key1 = this.getTileKey(tile1.getFamily(), tile1.getValue());
const key2 = this.getTileKey(tile2.getFamily(), tile2.getValue());
const indices1 = this.tileIndexMap.get(key1) || [];
const indices2 = this.tileIndexMap.get(key2) || [];
// Update indices
const idx1 = indices1.indexOf(index2);
const idx2 = indices2.indexOf(index1);
if (idx1 !== -1) indices1[idx1] = index1;
if (idx2 !== -1) indices2[idx2] = index2;
this.tileIndexMap.set(key1, indices1);
this.tileIndexMap.set(key2, indices2);
}
/**
* Decrements all indices after a shift operation
*/
private decrementIndicesAfterShift(): void {
for (const [key, indices] of this.tileIndexMap.entries()) {
this.tileIndexMap.set(
key,
indices
.filter(idx => idx !== 0) // Remove the index 0 that was shifted
.map(idx => (idx > 0 ? idx - 1 : idx)) // Decrement all indices
);
// Clean up empty entries
if (this.tileIndexMap.get(key)?.length === 0) {
this.tileIndexMap.delete(key);
}
}
}
/**
* Rebuilds the entire index map from scratch
*/
private rebuildIndexMap(): void {
this.tileIndexMap.clear();
for (let i = 0; i < this.tiles.length; i++) {
const tile = this.tiles[i];
const key = this.getTileKey(tile.getFamily(), tile.getValue());
const indices = this.tileIndexMap.get(key) || [];
indices.push(i);
this.tileIndexMap.set(key, indices);
}
}
/**
* Initializes the deck with all tiles
*/
private initTiles(allowRed: boolean): void {
// Create suits (families 1-3)
for (let family = 1; family <= 3; family++) {
for (let value = 1; value <= 9; value++) {
// Each value appears 4 times in a suit
const isRedFive = value === 5 && allowRed;
// Add 3 regular tiles
for (let i = 0; i < 3; i++) {
this.push(new Tile(family, value, false));
}
// Add the 4th tile (potentially red five)
this.push(new Tile(family, value, isRedFive));
}
}
// Create winds (family 4)
for (let value = 1; value <= 4; value++) {
for (let i = 0; i < 4; i++) {
this.push(new Tile(4, value, false));
}
}
// Create dragons (family 5)
for (let value = 1; value <= 3; value++) {
for (let i = 0; i < 4; i++) {
this.push(new Tile(5, value, false));
}
}
}
}

View file

@ -1,133 +0,0 @@
import { Rain } from "../rain"
// Configuration globale
const CANVAS_ID = "myCanvas";
const BG_RECT = { x: 0, y: 0, w: 1050, h: 1050 };
const MOUSE = { x: 0, y: 0 };
const FPS = 60; // Réduit de 90 à 60 pour de meilleures performances
const FRAME_INTERVAL = 1000 / FPS;
const RAIN = new Rain();
// Optimisation des références
let animationFrameId: number;
let lastFrameTime = 0;
let accumulatedTime = 0;
const callbacks: Array<() => void> = [];
const timeStep = 1 / FPS;
// Pré-calcul des dimensions
const canvas = document.getElementById(CANVAS_ID) as HTMLCanvasElement;
const ctx = canvas.getContext("2d", { alpha: false }) as NonNullable<CanvasRenderingContext2D>;
canvas.width = BG_RECT.w;
canvas.height = BG_RECT.h;
// Cache statique
const staticCanvas = document.createElement('canvas') as HTMLCanvasElement;
const staticCtx = staticCanvas.getContext("2d", { alpha: false }) as NonNullable<CanvasRenderingContext2D>;
staticCanvas.width = canvas.width;
staticCanvas.height = canvas.height;
// Optimisation du rendu avec requestAnimationFrame
function drawFrame(deltaTime: number) {
if (!ctx) return;
// Mise à jour avec pas de temps fixe pour stabilité physique
accumulatedTime += deltaTime / 1000; // Convertir en secondes
// Mettre à jour la simulation avec un pas de temps fixe
while (accumulatedTime >= timeStep) {
RAIN.update(timeStep);
accumulatedTime -= timeStep;
}
// Optimisation du rendu
staticCtx.fillStyle = "#007730"; // Couleur de fond
staticCtx.fillRect(BG_RECT.x, BG_RECT.y, BG_RECT.w, BG_RECT.h);
// Dessin de la pluie
RAIN.drawRain(staticCtx);
// Copier le buffer au canvas principal
ctx.drawImage(staticCanvas, 0, 0);
}
function animationLoop(currentTime: number) {
if (!lastFrameTime) {
lastFrameTime = currentTime;
animationFrameId = requestAnimationFrame(animationLoop);
return;
}
const deltaTime = currentTime - lastFrameTime;
lastFrameTime = currentTime;
// Limiter la fréquence des mises à jour si le navigateur est trop lent
if (deltaTime < 100) { // Ignorer les deltas trop grands (changement d'onglet, etc.)
drawFrame(deltaTime);
}
animationFrameId = requestAnimationFrame(animationLoop);
}
function initEventListeners() {
// Gestion des événements de redimensionnement et de pause
const handleVisibilityChange = () => {
if (document.hidden) {
cancelAnimationFrame(animationFrameId);
lastFrameTime = 0;
} else {
lastFrameTime = performance.now();
animationFrameId = requestAnimationFrame(animationLoop);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Ajouter le nettoyage de l'event listener
callbacks.push(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
}
export function cleanup() {
cancelAnimationFrame(animationFrameId);
callbacks.forEach(fn => fn());
}
export async function initDisplay() {
if (!ctx) {
console.error("Context canvas indisponible");
return;
}
console.log("Load beginning");
try {
// Préchargement des ressources
await RAIN.preloadRain();
console.log("Loading completed");
// Initialiser les écouteurs d'événements
initEventListeners();
// Démarrer la boucle d'animation avec le temps actuel
lastFrameTime = performance.now();
animationFrameId = requestAnimationFrame(animationLoop);
window.cleanup = cleanup;
} catch (error) {
console.error("Erreur lors de l'initialisation:", error);
}
}
// Déclaration globale pour TypeScript
declare global {
interface Window {
cleanup: () => void;
}
}
// Initialisation automatique si le script est chargé directement
if (typeof window !== 'undefined') {
initDisplay().catch(console.error);
}

View file

@ -1,111 +0,0 @@
import { Deck } from "../deck";
declare global {
interface Window {
clanup: () => void;
}
}
export { };
async function preloadDeck(deck: Deck) {
await deck.preload();
}
async function display() {
const canvas = document.getElementById("myCanvas") as HTMLCanvasElement;
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
// tapis
ctx.fillStyle = "#007730";
ctx.fillRect(0, 0, 1000, 1000);
// tuiles
let x = 130;
let y = 30;
let size = 0.75;
let xos = 40;
let yos = 100;
const deck = new Deck(false);
await preloadDeck(deck);
deck.displayFamilies(ctx, x, y, size, xos, yos);
// texte
ctx.fillStyle = "#DFDFFF";
ctx.font = "30px serif";
let delta = 50 * size;
let eps = 30 * size;
ctx.fillText("Caractère:", 5, y + delta + eps);
ctx.fillText("Rond:", 5, y + 3 * delta + yos * size + eps);
ctx.fillText("Bambou:", 5, y + 5 * delta + 2 * yos * size + eps);
ctx.fillText("Vent:", 5, y + 7 * delta + 3 * yos * size + eps);
ctx.fillText("Dragon:", 5, y + 9 * delta + 4 * yos * size + eps);
// familles
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 9; j++) {
ctx.fillText(
String(j+1),
x + j * (xos + 75) * size + 30 * size,
y + i * (yos + 100) * size + 150 * size
);
}
}
// vent
ctx.fillText(
"Est",
x + 0 * (xos + 75) * size,
y + 3 * (yos + 100) * size + 150 * size
);
ctx.fillText(
"Sud",
x + 1 * (xos + 75) * size,
y + 3 * (yos + 100) * size + 150 * size
);
ctx.fillText(
"Ouest",
x + 2 * (xos + 75) * size,
y + 3 * (yos + 100) * size + 150 * size
);
ctx.fillText(
"Nord",
x + 3 * (xos + 75) * size,
y + 3 * (yos + 100) * size + 150 * size
);
// dragon
ctx.fillText(
"Rouge",
x + 0 * (xos + 75) * size,
y + 4 * (yos + 100) * size + 150 * size
);
ctx.fillText(
"Vert",
x + 1 * (xos + 75) * size,
y + 4 * (yos + 100) * size + 150 * size
);
ctx.fillText(
"Blanc",
x + 2 * (xos + 75) * size,
y + 4 * (yos + 100) * size + 150 * size
);
window.cleanup = () => {
deck.cleanup();
}
} else {
console.error("Impossible d'obtenir le contexte du canvas.");
}
} else {
console.error("Canvas introuvable dans le DOM.");
}
}
display()

View file

@ -1,256 +0,0 @@
import { Deck } from "../deck";
import { Hand } from "../hand";
declare global {
interface Window {
cleanup: () => void;
}
}
export { };
class RiichiDisplay {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private offScreenCanvas: HTMLCanvasElement;
private offScreenCtx: CanvasRenderingContext2D;
private deck: Deck;
private hands: Hand[] = [];
private edeck: Deck;
private ehand: Hand;
private selectedTile: number | undefined = undefined;
private animationFrameId: number | null = null;
private isDirty: boolean = true;
// Constants
private readonly FPS: number = 30;
private readonly INTERVAL: number = 1000 / this.FPS;
private readonly X: number = 100;
private readonly Y: number = 150;
private readonly OS: number = 75;
private readonly SIZE: number = 0.75;
private readonly TILE_WIDTH: number = 78 * this.SIZE;
private readonly MAX_TILES: number = 14;
// Cache for mouse hit detection
private tileRects: Array<{x: number, y: number, width: number, height: number}> = [];
constructor() {
const canvas = document.getElementById("myCanvas") as HTMLCanvasElement;
if (!canvas) {
throw new Error("Canvas introuvable dans le DOM.");
}
this.canvas = canvas;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Impossible d'obtenir le contexte du canvas.");
}
this.ctx = ctx;
// Create off-screen canvas for double buffering
this.offScreenCanvas = document.createElement('canvas');
this.offScreenCanvas.width = canvas.width;
this.offScreenCanvas.height = canvas.height;
const offCtx = this.offScreenCanvas.getContext('2d');
if (!offCtx) {
throw new Error("Impossible d'obtenir le contexte du canvas hors écran.");
}
this.offScreenCtx = offCtx;
// Initialize decks
this.deck = new Deck(false);
this.edeck = new Deck(false);
// Initialize with empty hand (will be populated after preload)
this.ehand = new Hand();
// Set up event listeners
this.setupEventListeners();
// Calculate tile hit areas once
this.calculateTileHitAreas();
}
private calculateTileHitAreas(): void {
this.tileRects = [];
for (let i = 0; i < this.MAX_TILES; i++) {
this.tileRects.push({
x: this.X + i * this.TILE_WIDTH + (i === this.MAX_TILES - 1 ? 10 : 0),
y: 800,
width: 75,
height: 100 * this.SIZE
});
}
}
private setupEventListeners(): void {
this.canvas.addEventListener("mousemove", this.handleMouseMove.bind(this));
this.canvas.addEventListener("mousedown", this.handleMouseDown.bind(this));
}
private handleMouseMove(event: MouseEvent): void {
const rect = this.canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
// Check if cursor is over any tile using pre-calculated hit areas
const oldSelectedTile = this.selectedTile;
this.selectedTile = undefined;
for (let i = 0; i < this.tileRects.length; i++) {
const tileRect = this.tileRects[i];
if (
mouseX >= tileRect.x &&
mouseX <= tileRect.x + tileRect.width &&
mouseY >= tileRect.y &&
mouseY <= tileRect.y + tileRect.height
) {
this.selectedTile = i;
break;
}
}
// Only mark as dirty if selection changed
if (oldSelectedTile !== this.selectedTile) {
this.isDirty = true;
}
}
private handleMouseDown(): void {
if (this.selectedTile !== undefined) {
this.edeck.push(this.ehand.eject(this.selectedTile));
this.edeck.shuffle();
this.ehand.sort();
this.ehand.push(this.edeck.pop());
this.isDirty = true;
}
}
public async initialize(): Promise<void> {
// Preload all assets in parallel
await Promise.all([
this.deck.preload(),
this.edeck.preload()
]);
// Generate sample hands
for (let i = 0; i < 4; i++) {
const hand = this.deck.getRandomHand();
hand.sort();
this.hands.push(hand);
}
// Initialize interactive hand
this.ehand = this.edeck.getRandomHand();
this.ehand.push(this.edeck.pop());
this.ehand.sort();
// Initial draw
this.drawCanvas();
// Start animation loop
this.startAnimationLoop();
}
private drawCanvas(): void {
// Only redraw if something changed (dirty flag)
if (!this.isDirty) return;
const ctx = this.offScreenCtx;
// Clear canvas
ctx.clearRect(0, 0, this.offScreenCanvas.width, this.offScreenCanvas.height);
// Draw background
ctx.fillStyle = "#007730";
ctx.fillRect(0, 0, 1000, 1000);
// Draw title
ctx.fillStyle = "#DFDFFF";
ctx.font = "50px serif";
ctx.fillText("Exemples de main:", 15, 50);
// Draw example hands
for (let i = 0; i < this.hands.length; i++) {
this.hands[i].drawHand(
ctx,
this.X,
this.Y + i * this.SIZE * (100 + this.OS),
5,
this.SIZE
);
}
// Draw interactive hand
this.ehand.isolate = true;
this.ehand.drawHand(
ctx,
this.X,
800,
5,
this.SIZE,
this.selectedTile
);
// Flip double buffer
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.offScreenCanvas, 0, 0);
// Reset dirty flag
this.isDirty = false;
}
private startAnimationLoop(): void {
let lastTime = 0;
const animationLoop = (currentTime: number) => {
const deltaTime = currentTime - lastTime;
if (deltaTime >= this.INTERVAL) {
lastTime = currentTime - (deltaTime % this.INTERVAL);
this.drawCanvas();
}
this.animationFrameId = requestAnimationFrame(animationLoop);
};
this.animationFrameId = requestAnimationFrame(animationLoop);
}
public cleanup(): void {
// Cancel animation loop
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Clean up resources
this.deck.cleanup();
this.hands.forEach(hand => hand.cleanup());
this.hands = [];
this.edeck.cleanup();
this.ehand.cleanup();
// Clear canvases
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.offScreenCtx.clearRect(0, 0, this.offScreenCanvas.width, this.offScreenCanvas.height);
// Reset state
this.selectedTile = undefined;
this.isDirty = true;
}
}
// Initialize and start
const riichiDisplay = new RiichiDisplay();
riichiDisplay.initialize().catch(error => {
console.error("Erreur lors de l'initialisation:", error);
});
// Expose cleanup function for window
window.cleanup = () => {
riichiDisplay.cleanup();
};

View file

@ -1,381 +0,0 @@
import { Deck } from "../deck";
import { Hand } from "../hand";
import { Group } from "../group";
// Configuration globale
const CANVAS_ID = "myCanvas";
const BG_COLOR = "#007730";
const BG_RECT = { x: 50, y: 50, w: 1000, h: 1000 };
const FPS = 30;
const FRAME_INTERVAL = 1000 / FPS;
/**
* Classe RiichiDisplay responsable de la gestion de l'affichage du jeu Riichi Mahjong
*/
class RiichiDisplay {
// Canvas principal et contexte
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
// Canvas pour le double-buffering
private staticCanvas: HTMLCanvasElement;
private staticCtx: CanvasRenderingContext2D;
// Ressources du jeu
private decks: Array<Deck> = [];
private hands: Array<Hand> = [];
// État de l'interface
private selectedTile: number | undefined = undefined;
private isDirty: boolean = true;
// Animation et événements
private animationFrameId: number | null = null;
private lastFrameTime: number = 0;
private cleanupCallbacks: Array<() => void> = [];
// Constantes pour le rendu
private readonly BASE_X: number = 300;
private readonly BASE_Y: number = 150;
private readonly X_OFFSET: number = 250;
private readonly Y_OFFSET: number = 100;
private readonly INTERACTIVE_X: number = 100;
private readonly INTERACTIVE_Y: number = 800;
private readonly SIZE: number = 0.75;
private readonly TILE_WIDTH: number = 78 * this.SIZE;
private readonly MAX_TILES: number = 14;
// Zones de détection pour l'interaction souris
private tileRects: Array<{ x: number, y: number, width: number, height: number }> = [];
constructor(canvasId: string = CANVAS_ID) {
// Initialisation du canvas
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
if (!canvas) {
throw new Error(`Canvas avec ID '${canvasId}' introuvable`);
}
this.canvas = canvas;
// Récupération du contexte 2D
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Impossible d'obtenir le contexte 2D du canvas");
}
this.ctx = ctx;
// Initialisation du canvas pour le double-buffering
this.staticCanvas = document.createElement('canvas');
this.staticCanvas.width = canvas.width;
this.staticCanvas.height = canvas.height;
const staticCtx = this.staticCanvas.getContext("2d");
if (!staticCtx) {
throw new Error("Impossible d'obtenir le contexte du canvas statique");
}
this.staticCtx = staticCtx;
// Pré-calcul des zones de détection
this.calculateTileHitAreas();
}
/**
* Pré-calcule les zones de détection pour l'interaction souris
*/
private calculateTileHitAreas(): void {
this.tileRects = [];
for (let i = 0; i < this.MAX_TILES; i++) {
this.tileRects.push({
x: this.INTERACTIVE_X + i * this.TILE_WIDTH + (i === this.MAX_TILES - 1 ? 10 : 0),
y: this.INTERACTIVE_Y,
width: 75,
height: 100 * this.SIZE
});
}
}
/**
* Pré-rendu du fond statique
*/
private prerenderBackground(): void {
this.staticCtx.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height);
this.staticCtx.fillStyle = BG_COLOR;
this.staticCtx.fillRect(BG_RECT.x, BG_RECT.y, BG_RECT.w, BG_RECT.h);
}
/**
* Dessine une frame complète
*/
private drawFrame(): void {
// Vérifier si un redessinage est nécessaire
if (!this.isDirty) return;
// Pré-rendu du fond statique
this.prerenderBackground();
// Dessin des éléments statiques et dynamiques
this.drawHandsAndLabels();
// Affichage des informations sur les groupes
this.checkAndDisplayGroups();
// Copie du canvas statique vers le canvas principal
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.staticCanvas, 0, 0);
// Réinitialisation du flag de modification
this.isDirty = false;
}
/**
* Dessine les mains et leurs étiquettes
*/
private drawHandsAndLabels(): void {
const ctx = this.staticCtx;
// Configurer le style de texte
ctx.fillStyle = "#DFDFFF";
ctx.font = "50px serif";
// Dessiner les mains "Chii"
ctx.fillText("Chii:", 75, this.BASE_Y + 100 * this.SIZE - 5);
this.hands[0].drawHand(ctx, this.BASE_X, this.BASE_Y, 5, this.SIZE);
this.hands[1].drawHand(ctx, this.BASE_X + (75 + this.X_OFFSET) * this.SIZE, this.BASE_Y, 5, this.SIZE);
// Dessiner les mains "Pon"
ctx.fillText("Pon:", 75, this.BASE_Y + (100 + this.Y_OFFSET) * this.SIZE + 100 * this.SIZE - 5);
this.hands[2].drawHand(ctx, this.BASE_X, this.BASE_Y + (100 + this.Y_OFFSET) * this.SIZE, 5, this.SIZE);
this.hands[3].drawHand(ctx, this.BASE_X + (75 + this.X_OFFSET) * this.SIZE, this.BASE_Y + (100 + this.Y_OFFSET) * this.SIZE, 5, this.SIZE);
// Dessiner les mains "Invalide"
ctx.fillText("Invalide:", 75, this.BASE_Y + 2 * (100 + this.Y_OFFSET) * this.SIZE + 100 * this.SIZE - 5);
this.hands[5].drawHand(ctx, this.BASE_X, this.BASE_Y + 2 * (100 + this.Y_OFFSET) * this.SIZE, 5, this.SIZE);
this.hands[6].drawHand(ctx, this.BASE_X + (75 + this.X_OFFSET) * this.SIZE, this.BASE_Y + 2 * (100 + this.Y_OFFSET) * this.SIZE, 5, this.SIZE);
this.hands[7].drawHand(ctx, this.BASE_X + 2 * (75 + this.X_OFFSET) * this.SIZE, this.BASE_Y + 2 * (100 + this.Y_OFFSET) * this.SIZE, 5, this.SIZE);
// Main supplémentaire (position spéciale)
this.hands[4].drawHand(ctx, this.BASE_X + 2 * (75 + this.X_OFFSET) * this.SIZE, this.BASE_Y + (100 + this.Y_OFFSET) * this.SIZE, 5, this.SIZE);
// Main interactive
this.hands[8].isolate = true;
this.hands[8].drawHand(ctx, this.INTERACTIVE_X, this.INTERACTIVE_Y, 5, this.SIZE, this.selectedTile);
}
/**
* Vérifie et affiche les informations sur les groupes formés
*/
private checkAndDisplayGroups(): void {
const groups = this.hands[8].toGroup();
if (groups !== undefined) {
this.staticCtx.fillStyle = "#FF0000";
this.staticCtx.font = "50px serif";
this.staticCtx.fillText("Tous les groupes sont formés !", 100, 750);
}
}
/**
* Démarre la boucle d'animation
*/
private startAnimationLoop(): void {
const animationLoop = (currentTime: number) => {
this.animationFrameId = requestAnimationFrame(animationLoop);
const deltaTime = currentTime - this.lastFrameTime;
if (deltaTime < FRAME_INTERVAL) return;
this.lastFrameTime = currentTime - (deltaTime % FRAME_INTERVAL);
this.drawFrame();
};
this.animationFrameId = requestAnimationFrame(animationLoop);
}
/**
* Initialise les écouteurs d'événements
*/
private initEventListeners(): void {
const mouseMoveHandler = this.handleMouseMove.bind(this);
const mouseDownHandler = this.handleMouseDown.bind(this);
this.canvas.addEventListener('mousemove', mouseMoveHandler);
this.canvas.addEventListener('mousedown', mouseDownHandler);
// Enregistrer les callbacks de nettoyage
this.cleanupCallbacks.push(() => {
this.canvas.removeEventListener('mousemove', mouseMoveHandler);
this.canvas.removeEventListener('mousedown', mouseDownHandler);
});
}
/**
* Gère le mouvement de la souris
*/
private handleMouseMove(e: MouseEvent): void {
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Sauvegarde de l'état précédent pour détecter les changements
const previousSelectedTile = this.selectedTile;
this.selectedTile = undefined;
// Détection optimisée avec zones pré-calculées
for (let i = 0; i < this.tileRects.length; i++) {
const tileRect = this.tileRects[i];
if (
mouseX >= tileRect.x &&
mouseX <= tileRect.x + tileRect.width &&
mouseY >= tileRect.y &&
mouseY <= tileRect.y + tileRect.height
) {
this.selectedTile = i;
break;
}
}
// Marquer comme "dirty" uniquement si la sélection a changé
if (previousSelectedTile !== this.selectedTile) {
this.isDirty = true;
}
}
/**
* Gère le clic de souris
*/
private handleMouseDown(): void {
if (this.selectedTile !== undefined) {
// Exécuter l'action pour la tuile sélectionnée
this.decks[0].push(this.hands[8].eject(this.selectedTile));
this.decks[0].shuffle();
this.hands[8].sort();
this.hands[8].push(this.decks[0].pop());
// Marquer comme "dirty" pour forcer le redessinage
this.isDirty = true;
}
}
/**
* Précharge un deck
*/
private async preloadDeck(deck: Deck): Promise<void> {
await deck.preload();
}
/**
* Précharge une main
*/
private async preloadHand(hand: Hand): Promise<void> {
await hand.preload();
}
/**
* Initialise l'affichage et démarre l'application
*/
public async initialize(): Promise<void> {
try {
// Création du deck principal
this.decks.push(new Deck(false));
// Création des mains prédéfinies
this.hands.push(
new Hand("s1s2s3"), // Chii 1
new Hand("m2m3m4"), // Chii 2
new Hand("p5p5p5"), // Pon 1
new Hand("w2w2w2"), // Pon 2
new Hand("d3d3d3"), // Pon supplémentaire
new Hand("s4p5m6"), // Invalide 1
new Hand("m9s9p9"), // Invalide 2
new Hand("d1d2d3"), // Invalide 3
this.decks[0].getRandomHand() // Main interactive
);
// Préchargement parallèle des ressources
await Promise.all([
...this.decks.map(deck => this.preloadDeck(deck)),
...this.hands.map(hand => this.preloadHand(hand))
]);
// Configuration de la main interactive
this.hands[8].push(this.decks[0].pop());
this.hands[8].sort();
// Initialisation des écouteurs d'événements
this.initEventListeners();
// Premier rendu
this.isDirty = true;
this.drawFrame();
// Démarrage de la boucle d'animation
this.startAnimationLoop();
} catch (error) {
console.error("Erreur d'initialisation:", error);
}
}
/**
* Nettoie les ressources
*/
public cleanup(): void {
// Arrêter la boucle d'animation
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Exécuter tous les callbacks de nettoyage
this.cleanupCallbacks.forEach(callback => callback());
this.cleanupCallbacks = [];
// Nettoyer les ressources des decks et des mains
this.decks.forEach(deck => deck.cleanup());
this.hands.forEach(hand => hand.cleanup());
// Vider les collections
this.decks = [];
this.hands = [];
// Effacer les canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.staticCtx.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height);
}
}
// Instance globale pour le nettoyage
let displayInstance: RiichiDisplay | null = null;
/**
* Fonction de nettoyage exportée
*/
export function cleanup(): void {
if (displayInstance) {
displayInstance.cleanup();
displayInstance = null;
}
}
/**
* Fonction d'initialisation exportée
*/
export async function initDisplay(): Promise<void> {
try {
displayInstance = new RiichiDisplay(CANVAS_ID);
await displayInstance.initialize();
window.cleanup = cleanup;
} catch (error) {
console.error("Erreur lors de l'initialisation:", error);
}
}
// Déclaration globale pour TypeScript
declare global {
interface Window {
cleanup: () => void;
}
}
// Initialisation automatique si le script est chargé directement
if (typeof window !== 'undefined') {
initDisplay().catch(console.error);
}

View file

@ -1,275 +0,0 @@
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 RiichiGameManager {
// Configuration globale
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;
// Singleton instance
private static instance: RiichiGameManager;
// Canvas et contextes
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private staticCanvas: HTMLCanvasElement;
private staticCtx: CanvasRenderingContext2D;
// État du jeu
private mouse = { x: 0, y: 0 };
private game: Game | null = null;
private decks: Array<Deck> = [];
private hands: Array<Hand> = [];
// Animation et boucle de jeu
private animationFrameId: number | null = null;
private lastFrameTime: number = 0;
private isInitialized: boolean = false;
// Nettoyage
private cleanupCallbacks: Array<() => void> = [];
/**
* Constructeur privé pour le Singleton
*/
private constructor() {
// Initialisation du canvas principal
const canvas = document.getElementById(this.CANVAS_ID) as HTMLCanvasElement;
if (!canvas) {
throw new Error(`Canvas avec ID '${this.CANVAS_ID}' introuvable`);
}
this.canvas = canvas;
this.canvas.width = this.BG_RECT.w;
this.canvas.height = this.BG_RECT.h;
// Récupération du contexte 2D
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) {
throw new Error("Impossible d'obtenir le contexte 2D du canvas");
}
this.ctx = ctx;
// Initialisation du canvas pour le double-buffering
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("Impossible d'obtenir le contexte du canvas statique");
}
this.staticCtx = staticCtx;
}
/**
* Accès à l'instance Singleton
*/
public static getInstance(): RiichiGameManager {
if (!RiichiGameManager.instance) {
RiichiGameManager.instance = new RiichiGameManager();
}
return RiichiGameManager.instance;
}
/**
* Dessine une frame du jeu
*/
private drawFrame(): void {
if (this.game) {
this.game.draw(this.mouse);
}
}
/**
* Démarre la boucle d'animation du jeu
*/
private startAnimationLoop(): void {
const animationLoop = (currentTime: number) => {
this.animationFrameId = requestAnimationFrame(animationLoop);
const deltaTime = currentTime - this.lastFrameTime;
if (deltaTime < this.FRAME_INTERVAL) return;
// Correction du time drift
this.lastFrameTime = currentTime - (deltaTime % this.FRAME_INTERVAL);
// Rendu d'une frame
this.drawFrame();
};
this.animationFrameId = requestAnimationFrame(animationLoop);
}
/**
* Initialise les écouteurs d'événements
*/
private initEventListeners(): void {
// Handler pour le déplacement de la souris
const mouseMoveHandler = (e: MouseEvent) => {
// Calcul des coordonnées relatives au canvas
const rect = this.canvas.getBoundingClientRect();
this.mouse.x = e.clientX - rect.left;
this.mouse.y = e.clientY - rect.top;
};
// Handler pour le clic de souris
const mouseDownHandler = (e: MouseEvent) => {
if (this.game) {
this.game.click(e);
}
};
// Enregistrement des écouteurs
this.canvas.addEventListener('mousemove', mouseMoveHandler);
this.canvas.addEventListener('mousedown', mouseDownHandler);
// Enregistrement des callbacks de nettoyage
this.cleanupCallbacks.push(() => {
this.canvas.removeEventListener('mousemove', mouseMoveHandler);
this.canvas.removeEventListener('mousedown', mouseDownHandler);
});
}
/**
* Précharge un deck
*/
private async preloadDeck(deck: Deck): Promise<void> {
await deck.preload();
}
/**
* Précharge une main
*/
private async preloadHand(hand: Hand): Promise<void> {
await hand.preload();
}
/**
* Initialise le jeu
*/
public async initialize(): Promise<void> {
if (this.isInitialized) return;
try {
console.log("Chargement en cours...");
// Création du jeu
this.game = new Game(
this.ctx,
this.canvas,
this.staticCtx,
this.staticCanvas
);
// Préchargement parallèle des ressources
await Promise.all([
...this.decks.map(deck => this.preloadDeck(deck)),
...this.hands.map(hand => this.preloadHand(hand)),
this.game.preload()
]);
console.log("Chargement terminé");
// Initialisation des écouteurs d'événements
this.initEventListeners();
// Démarrage de la boucle d'animation
this.startAnimationLoop();
// Marquer comme initialisé
this.isInitialized = true;
// Définir la fonction de nettoyage global
window.cleanup = this.cleanup.bind(this);
} catch (error) {
console.error("Erreur lors de l'initialisation:", error);
}
}
/**
* Nettoie les ressources
*/
public cleanup(): void {
// Arrêter la boucle d'animation
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Exécuter tous les callbacks de nettoyage
this.cleanupCallbacks.forEach(callback => callback());
this.cleanupCallbacks = [];
// Nettoyer les ressources du jeu
if (this.game) {
// Supposons que Game a une méthode cleanup
(this.game as any).cleanup?.();
this.game = null;
}
// Nettoyer les ressources des decks et des mains
this.decks.forEach(deck => deck.cleanup());
this.hands.forEach(hand => hand.cleanup());
// Vider les collections
this.decks = [];
this.hands = [];
// Réinitialiser l'état
this.isInitialized = false;
this.lastFrameTime = 0;
this.mouse = { x: 0, y: 0 };
// Effacer les canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.staticCtx.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height);
}
}
// Fonction de nettoyage globale exportée
export function cleanup(): void {
RiichiGameManager.getInstance().cleanup();
}
// Fonction d'initialisation globale exportée
export async function initDisplay(): Promise<void> {
await RiichiGameManager.getInstance().initialize();
}
// Déclaration globale pour TypeScript
declare global {
interface Window {
cleanup: () => void;
}
}
// Initialisation automatique si le script est chargé directement
if (typeof window !== 'undefined') {
showPlayButton();
}

View file

@ -1,120 +0,0 @@
import { Deck } from "../deck";
import { Hand } from "../hand";
import { Game } from "../game"
// Configuration globale
const CANVAS_ID = "myCanvas";
const BG_RECT = { x: 0, y: 0, w: 1050, h: 1050 };
var MOUSE = { x: 0, y: 0 };
const FPS = 30;
const FRAME_INTERVAL = 1000 / FPS;
// variables globales
const DECKS: Array<Deck> = [];
const HANDS: Array<Hand> = [];
var GAME: Game|undefined;
// Optimisation des références
let animationFrameId: number;
let lastFrameTime = 0;
const callbacks: Array<() => void> = [];
// Pré-calcul des dimensions
const canvas = document.getElementById(CANVAS_ID) as HTMLCanvasElement;
const ctx = canvas.getContext("2d") as NonNullable<CanvasRenderingContext2D>;
canvas.width = BG_RECT.w;
canvas.height = BG_RECT.h;
// Cache statique
const staticCanvas = document.createElement('canvas') as HTMLCanvasElement;
const staticCtx = staticCanvas.getContext("2d") as NonNullable<CanvasRenderingContext2D>;
staticCanvas.width = canvas.width;
staticCanvas.height = canvas.height;
function drawFrame() {
if (!ctx) return;
GAME?.draw(MOUSE);
}
function animationLoop(currentTime: number) {
animationFrameId = requestAnimationFrame(animationLoop);
const deltaTime = currentTime - lastFrameTime;
if (deltaTime < FRAME_INTERVAL) return;
lastFrameTime = currentTime - (deltaTime % FRAME_INTERVAL);
drawFrame();
}
function initEventListeners() {
const handlers = {
mousedown: (e: MouseEvent) => {
GAME?.click(e);
},
mousemove: (e: MouseEvent) => {
MOUSE.x = e.x;
MOUSE.y = e.y;
}
};
callbacks.push(() => {
canvas.removeEventListener('mousemove', handlers.mousemove);
});
canvas.addEventListener('mousedown', handlers.mousedown);
}
async function preloadDeck(deck: Deck) {
await deck.preload();
}
async function preloadHand(hand: Hand) {
await hand.preload();
}
export function cleanup() {
cancelAnimationFrame(animationFrameId);
callbacks.forEach(fn => fn());
}
export async function initDisplay() {
if (!ctx) {
console.error("Context canvas indisponible");
return;
}
console.log("Load begining\n");
// Préchargement des ressources si nécessaire
// const deck = new Deck();
// await preloadDeck(deck);
// Charge et affiche l'image "ron.png" sur la partie gauche du canvas
try {
const ronImg = new Image();
ronImg.src = "img/ron.png";
await new Promise<void>((resolve) => {
ronImg.onload = () => resolve();
ronImg.onerror = () => {
console.warn("Impossible de charger img/ron.png");
resolve();
};
});
// Dessin de l'image en plein écran (remplit tout le canvas)
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(ronImg, 0, 0, canvas.width, canvas.height);
} catch (err) {
console.error("Erreur lors du rendu de ron.png", err);
}
}
// Déclaration globale pour TypeScript
declare global {
interface Window {
cleanup: () => void;
}
}
// Initialisation automatique si le script est chargé directement
if (typeof window !== 'undefined') {
initDisplay().catch(console.error);
}

View file

@ -1,278 +0,0 @@
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 RiichiGameManager {
// Configuration globale
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;
// Singleton instance
private static instance: RiichiGameManager;
// Canvas et contextes
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private staticCanvas: HTMLCanvasElement;
private staticCtx: CanvasRenderingContext2D;
// État du jeu
private mouse = { x: 0, y: 0 };
private game: Game | null = null;
private decks: Array<Deck> = [];
private hands: Array<Hand> = [];
// Animation et boucle de jeu
private animationFrameId: number | null = null;
private lastFrameTime: number = 0;
private isInitialized: boolean = false;
// Nettoyage
private cleanupCallbacks: Array<() => void> = [];
/**
* Constructeur privé pour le Singleton
*/
private constructor() {
// Initialisation du canvas principal
const canvas = document.getElementById(this.CANVAS_ID) as HTMLCanvasElement;
if (!canvas) {
throw new Error(`Canvas avec ID '${this.CANVAS_ID}' introuvable`);
}
this.canvas = canvas;
this.canvas.width = this.BG_RECT.w;
this.canvas.height = this.BG_RECT.h;
// Récupération du contexte 2D
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) {
throw new Error("Impossible d'obtenir le contexte 2D du canvas");
}
this.ctx = ctx;
// Initialisation du canvas pour le double-buffering
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("Impossible d'obtenir le contexte du canvas statique");
}
this.staticCtx = staticCtx;
}
/**
* Accès à l'instance Singleton
*/
public static getInstance(): RiichiGameManager {
if (!RiichiGameManager.instance) {
RiichiGameManager.instance = new RiichiGameManager();
}
return RiichiGameManager.instance;
}
/**
* Dessine une frame du jeu
*/
private drawFrame(): void {
if (this.game) {
this.game.draw(this.mouse);
}
}
/**
* Démarre la boucle d'animation du jeu
*/
private startAnimationLoop(): void {
const animationLoop = (currentTime: number) => {
this.animationFrameId = requestAnimationFrame(animationLoop);
const deltaTime = currentTime - this.lastFrameTime;
if (deltaTime < this.FRAME_INTERVAL) return;
// Correction du time drift
this.lastFrameTime = currentTime - (deltaTime % this.FRAME_INTERVAL);
// Rendu d'une frame
this.drawFrame();
};
this.animationFrameId = requestAnimationFrame(animationLoop);
}
/**
* Initialise les écouteurs d'événements
*/
private initEventListeners(): void {
// Handler pour le déplacement de la souris
const mouseMoveHandler = (e: MouseEvent) => {
// Calcul des coordonnées relatives au canvas
const rect = this.canvas.getBoundingClientRect();
this.mouse.x = e.clientX - rect.left;
this.mouse.y = e.clientY - rect.top;
};
// Handler pour le clic de souris
const mouseDownHandler = (e: MouseEvent) => {
if (this.game) {
this.game.click(e);
}
};
// Enregistrement des écouteurs
this.canvas.addEventListener('mousemove', mouseMoveHandler);
this.canvas.addEventListener('mousedown', mouseDownHandler);
// Enregistrement des callbacks de nettoyage
this.cleanupCallbacks.push(() => {
this.canvas.removeEventListener('mousemove', mouseMoveHandler);
this.canvas.removeEventListener('mousedown', mouseDownHandler);
});
}
/**
* Précharge un deck
*/
private async preloadDeck(deck: Deck): Promise<void> {
await deck.preload();
}
/**
* Précharge une main
*/
private async preloadHand(hand: Hand): Promise<void> {
await hand.preload();
}
/**
* Initialise le jeu
*/
public async initialize(): Promise<void> {
if (this.isInitialized) return;
try {
console.log("Chargement en cours...");
// Création du jeu
this.game = new Game(
this.ctx,
this.canvas,
this.staticCtx,
this.staticCanvas,
false,
0,
Math.floor(Math.random() * 4)
);
// Préchargement parallèle des ressources
await Promise.all([
...this.decks.map(deck => this.preloadDeck(deck)),
...this.hands.map(hand => this.preloadHand(hand)),
this.game.preload()
]);
console.log("Chargement terminé");
// Initialisation des écouteurs d'événements
this.initEventListeners();
// Démarrage de la boucle d'animation
this.startAnimationLoop();
// Marquer comme initialisé
this.isInitialized = true;
// Définir la fonction de nettoyage global
window.cleanup = this.cleanup.bind(this);
} catch (error) {
console.error("Erreur lors de l'initialisation:", error);
}
}
/**
* Nettoie les ressources
*/
public cleanup(): void {
// Arrêter la boucle d'animation
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Exécuter tous les callbacks de nettoyage
this.cleanupCallbacks.forEach(callback => callback());
this.cleanupCallbacks = [];
// Nettoyer les ressources du jeu
if (this.game) {
// Supposons que Game a une méthode cleanup
(this.game as any).cleanup?.();
this.game = null;
}
// Nettoyer les ressources des decks et des mains
this.decks.forEach(deck => deck.cleanup());
this.hands.forEach(hand => hand.cleanup());
// Vider les collections
this.decks = [];
this.hands = [];
// Réinitialiser l'état
this.isInitialized = false;
this.lastFrameTime = 0;
this.mouse = { x: 0, y: 0 };
// Effacer les canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.staticCtx.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height);
}
}
// Fonction de nettoyage globale exportée
export function cleanup(): void {
RiichiGameManager.getInstance().cleanup();
}
// Fonction d'initialisation globale exportée
export async function initDisplay(): Promise<void> {
await RiichiGameManager.getInstance().initialize();
}
// Déclaration globale pour TypeScript
declare global {
interface Window {
cleanup: () => void;
}
}
// Initialisation automatique si le script est chargé directement
if (typeof window !== 'undefined') {
showPlayButton();
}

View file

@ -1,202 +0,0 @@
import { Hand } from "../hand";
import { function_generator } from "../yakus/generator"
// Configuration globale
const CANVAS_ID = "myCanvas";
const FPS = 30;
const FRAME_INTERVAL = 1000 / FPS;
/**
* Classe RiichiDisplay responsable de la gestion de l'affichage du jeu Riichi Mahjong
*/
class RiichiDisplay {
// Canvas principal et contexte
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
// Ressources du jeu
private hands: Array<Hand> = [];
private handTexts: Array<string> = [];
private wind: number = Math.floor(Math.random() * 4);
// Animation et événements
private animationFrameId: number | null = null;
private lastFrameTime: number = 0;
private cleanupCallbacks: Array<() => void> = [];
// Constantes pour le rendu
private readonly BASE_X: number = 50;
private readonly BASE_Y: number = 90;
private readonly DY = 160;
private readonly SIZE: number = 0.75;
constructor(canvasId: string = CANVAS_ID) {
// Initialisation du canvas
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
if (!canvas) {
throw new Error(`Canvas avec ID '${canvasId}' introuvable`);
}
this.canvas = canvas;
// Récupération du contexte 2D
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Impossible d'obtenir le contexte 2D du canvas");
}
this.ctx = ctx;
}
/**
* Dessine une frame complète
*/
private drawFrame(): void {
// Copie du canvas statique vers le canvas principal
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Dessin des éléments statiques et dynamiques
this.drawHandsAndLabels();
}
/**
* Dessine les mains et leurs étiquettes
*/
private drawHandsAndLabels(): void {
for (let i = 0; i < this.hands.length; i++) {
this.ctx.fillStyle = "#DFDFFF";
this.ctx.font = "40px serif";
this.ctx.fillText(this.handTexts[i], this.BASE_X, this.BASE_Y - 20 + i * this.DY);
this.hands[i].drawHand(
this.ctx,
this.BASE_X,
this.BASE_Y + i * this.DY,
5,
this.SIZE
);
}
}
/**
* Démarre la boucle d'animation
*/
private startAnimationLoop(): void {
const animationLoop = (currentTime: number) => {
this.animationFrameId = requestAnimationFrame(animationLoop);
const deltaTime = currentTime - this.lastFrameTime;
if (deltaTime < FRAME_INTERVAL) return;
this.lastFrameTime = currentTime - (deltaTime % FRAME_INTERVAL);
this.drawFrame();
};
this.animationFrameId = requestAnimationFrame(animationLoop);
}
/**
* Précharge une main
*/
private async preloadHand(hand: Hand): Promise<void> {
await hand.preload();
}
/**
* Initialise l'affichage et démarre l'application
*/
public async initialize(): Promise<void> {
try {
// Création des mains prédéfinies
this.hands.push(
function_generator.ordinaires(),
function_generator.brelan_valeur(this.wind),
function_generator.main_pure(),
function_generator.main_semi_pure(),
function_generator.double_suite(),
function_generator.sept_pairs()
);
this.handTexts.push(
"Tout ordinaires :",
"Brelan de valeur (" + ["Est", "Sud", "Ouest", "Nord"][this.wind] + ") :",
"Main pure :",
"Main semi-pure :",
"Double suite :",
"Sept pairs :"
);
// Préchargement parallèle des ressources
await Promise.all([
...this.hands.map(hand => this.preloadHand(hand))
]);
// Premier rendu
this.drawFrame();
// Démarrage de la boucle d'animation
this.startAnimationLoop();
} catch (error) {
console.error("Erreur d'initialisation:", error);
}
}
/**
* Nettoie les ressources
*/
public cleanup(): void {
// Arrêter la boucle d'animation
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Exécuter tous les callbacks de nettoyage
this.cleanupCallbacks.forEach(callback => callback());
this.cleanupCallbacks = [];
// Nettoyer les ressources des decks et des mains
this.hands.forEach(hand => hand.cleanup());
// Vider les collections
this.hands = [];
}
}
// Instance globale pour le nettoyage
let displayInstance: RiichiDisplay | null = null;
/**
* Fonction de nettoyage exportée
*/
export function cleanup(): void {
if (displayInstance) {
displayInstance.cleanup();
displayInstance = null;
}
}
/**
* Fonction d'initialisation exportée
*/
export async function initDisplay(): Promise<void> {
try {
displayInstance = new RiichiDisplay(CANVAS_ID);
await displayInstance.initialize();
window.cleanup = cleanup;
} catch (error) {
console.error("Erreur lors de l'initialisation:", error);
}
}
// Déclaration globale pour TypeScript
declare global {
interface Window {
cleanup: () => void;
}
}
// Initialisation automatique si le script est chargé directement
if (typeof window !== 'undefined') {
initDisplay().catch(console.error);
}

View file

@ -1,222 +0,0 @@
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

@ -1,126 +0,0 @@
import { Deck } from "../deck";
import { Hand } from "../hand";
// Configuration globale
const CANVAS_ID = "myCanvas";
const BG_COLOR = "#007730";
const BG_RECT = { x: 0, y: 0, w: 1000, h: 1000 };
const FPS = 30;
const FRAME_INTERVAL = 1000 / FPS;
// variables globales
const DECKS: Array<Deck> = [];
const HANDS: Array<Hand> = [];
// Optimisation des références
let animationFrameId: number;
let lastFrameTime = 0;
const callbacks: Array<() => void> = [];
// Pré-calcul des dimensions
const canvas = document.getElementById(CANVAS_ID) as HTMLCanvasElement;
const ctx = canvas.getContext("2d") as NonNullable<CanvasRenderingContext2D>;
// Cache statique
const staticCanvas = document.createElement('canvas') as HTMLCanvasElement;
const staticCtx = staticCanvas.getContext("2d") as NonNullable<CanvasRenderingContext2D>;
staticCanvas.width = canvas.width;
staticCanvas.height = canvas.height;
// Pré-rendu du fond
function prerenderBackground() {
staticCtx.clearRect(0, 0, canvas.width, canvas.height);
staticCtx.fillStyle = BG_COLOR;
staticCtx.fillRect(BG_RECT.x, BG_RECT.y, BG_RECT.w, BG_RECT.h);
};
function drawFrame() {
if (!ctx) return;
// Effacement intelligent (uniquement la zone nécessaire)
prerenderBackground();
// Ici viendrait le dessin des éléments dynamiques
// Par exemple:
// drawDeck();
// drawHands();
// Dessin du cache statique
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(staticCanvas, 0, 0);
}
function animationLoop(currentTime: number) {
animationFrameId = requestAnimationFrame(animationLoop);
const deltaTime = currentTime - lastFrameTime;
if (deltaTime < FRAME_INTERVAL) return;
lastFrameTime = currentTime - (deltaTime % FRAME_INTERVAL);
drawFrame();
}
function initEventListeners() {
const handlers = {
mousemove: (e: MouseEvent) => {
// Logique de gestion du mouvement de la souris
},
mousedown: (e: MouseEvent) => {
// Logique de gestion du clic de souris
}
};
callbacks.push(() => {
canvas.removeEventListener('mousemove', handlers.mousemove);
canvas.removeEventListener('mousedown', handlers.mousedown);
});
canvas.addEventListener('mousemove', handlers.mousemove);
canvas.addEventListener('mousedown', handlers.mousedown);
}
async function preloadDeck(deck: Deck) {
await deck.preload();
}
async function preloadHand(hand: Hand) {
await hand.preload();
}
export function cleanup() {
cancelAnimationFrame(animationFrameId);
callbacks.forEach(fn => fn());
}
export async function initDisplay() {
if (!ctx) {
console.error("Context canvas indisponible");
return;
}
// Préchargement des ressources si nécessaire
// const deck = new Deck();
// await preloadDeck(deck);
DECKS.push(
);
HANDS.push(
);
await Promise.all(DECKS.map(d => preloadDeck(d)));
await Promise.all(HANDS.map(h => preloadHand(h)));
initEventListeners();
requestAnimationFrame(animationLoop);
window.cleanup = cleanup;
}
// Déclaration globale pour TypeScript
declare global {
interface Window {
cleanup: () => void;
}
}
// Initialisation automatique si le script est chargé directement
if (typeof window !== 'undefined') {
initDisplay().catch(console.error);
}

File diff suppressed because it is too large Load diff

View file

@ -1,91 +0,0 @@
import { Tile } from "./tile";
export class Group {
constructor(
private tiles: Array<Tile>,
private stolenFrom: number,
private belongsTo: number
) {}
public push(tile: Tile): void {
this.tiles.push(tile);
}
public pop(): Tile | undefined {
return this.tiles.pop();
}
public getTiles(): Array<Tile> {
return this.tiles;
}
public compare(g: Group): number {
// Compare les premiers tiles, puis les seconds si égalité
const firstComparison = this.tiles[0].compare(g.tiles[0]);
return firstComparison !== 0 ? firstComparison : this.tiles[1].compare(g.tiles[1]);
}
public drawGroup(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
os: number,
size: number,
rotation: number,
selectedTile?: Tile
): void {
// Sauvegarde et rotation du contexte
ctx.save();
ctx.translate(525, 525);
ctx.rotate(rotation);
ctx.translate(-525, -525);
// Calcul des paramètres de dessin
const v = 75 * size;
const w = 90 * size;
const osy = 25 * size / 2;
const p = (this.belongsTo - this.stolenFrom - 1 + 4) % 4;
// Détermination du tile sélectionné
const sf = selectedTile === undefined ? -1 : selectedTile.getFamily();
const sv = selectedTile === undefined ? 0 : selectedTile.getValue();
// Fonction helper pour éviter la répétition de code
const drawTile = (tile: Tile, tx: number, ty: number, angle: number) => {
tile.drawTile(
ctx,
tx,
ty,
size,
false,
angle,
tile.isEqual(sf, sv)
);
};
const HALF_PI = Math.PI / 2;
// Dessin selon la position
switch (p) {
case 0:
drawTile(this.tiles[0], x, y + osy, HALF_PI);
drawTile(this.tiles[1], x + w, y, 0);
drawTile(this.tiles[2], x + w + v + os * size, y, 0);
break;
case 1:
drawTile(this.tiles[0], x, y, 0);
drawTile(this.tiles[1], x + w, y + osy, -HALF_PI);
drawTile(this.tiles[2], x + w + v + 3 * os * size, y, 0);
break;
case 2:
drawTile(this.tiles[0], x, y, 0);
drawTile(this.tiles[1], x + v + os * size, y, 0);
drawTile(this.tiles[2], x + w + v + os * size, y + osy, -HALF_PI);
break;
default:
console.error(`Position non prise en charge: ${p}`);
}
ctx.restore();
}
}

View file

@ -1,382 +0,0 @@
import { Tile } from "./tile";
import { Group } from "./group";
// Constants to avoid magic numbers and improve readability
const TILE_TYPES = {
MANZU: { code: "m", family: 1 },
PINZU: { code: "p", family: 2 },
SOUZU: { code: "s", family: 3 },
WINDS: { code: "w", family: 4 },
DRAGONS: { code: "d", family: 5 }
};
// Helper class for grouping operations
class GroupFinder {
private tiles: Array<Tile>;
constructor(tiles: Array<Tile>) {
// Create deep copies of the tiles to avoid modifying originals
this.tiles = tiles.map(t => new Tile(t.getFamily(), t.getValue(), t.isRed()));
this.sort();
}
public sort(): void {
this.tiles.sort((a, b) => a.isLessThan(b) ? -1 : 1);
}
public find(family: number, value: number): Tile | undefined {
const index = this.findTileIndex(family, value);
if (index !== -1) {
[this.tiles[index], this.tiles[0]] = [this.tiles[0], this.tiles[index]];
const tile = this.tiles.shift();
this.sort();
return tile;
}
return undefined;
}
private findTileIndex(family: number, value: number): number {
return this.tiles.findIndex(
tile => tile.getFamily() === family && tile.getValue() === value
);
}
public count(family: number, value: number): number {
return this.tiles.filter(
tile => tile.getFamily() === family && tile.getValue() === value
).length;
}
// Find groups recursively without modifying original tiles
public findGroups(pair: boolean = false): Array<Group> | undefined {
if (this.tiles.length === 0) {
return [];
}
// Take last tile to try forming a group
const lastTile = this.tiles.pop() as Tile;
const family = lastTile.getFamily();
const value = lastTile.getValue();
// Try to form a pair
if (this.count(family, value) >= 1 && !pair) {
const result = this.tryFormPair(lastTile);
if (result) return result;
}
// Try to form a triplet (pon)
if (this.count(family, value) >= 2) {
const result = this.tryFormTriplet(lastTile, pair);
if (result) return result;
}
// Try to form a sequence (chii)
if (family <= 3) { // Only suit tiles can form sequences
const hasMinusOne = this.count(family, value - 1) > 0;
const hasMinusTwo = this.count(family, value - 2) > 0;
if (hasMinusOne && hasMinusTwo) {
const result = this.tryFormSequence(lastTile, pair);
if (result) return result;
}
}
// If no valid group could be formed, put tile back and return undefined
this.tiles.push(lastTile);
this.sort();
return undefined;
}
private tryFormPair(tile: Tile): Array<Group> | undefined {
const pairTile = this.find(tile.getFamily(), tile.getValue()) as Tile;
const groups = this.findGroups(true);
// Put the tile back
this.tiles.push(pairTile);
this.sort();
if (groups !== undefined) {
this.tiles.push(tile);
this.sort();
groups.push(new Group([tile, pairTile], 0, 0));
return groups;
}
return undefined;
}
private tryFormTriplet(tile: Tile, pair: boolean): Array<Group> | undefined {
const secondTile = this.find(tile.getFamily(), tile.getValue()) as Tile;
const thirdTile = this.find(tile.getFamily(), tile.getValue()) as Tile;
const groups = this.findGroups(pair);
// Put tiles back
this.tiles.push(secondTile);
this.tiles.push(thirdTile);
this.sort();
if (groups !== undefined) {
groups.push(new Group([tile, secondTile, thirdTile], 0, 0));
this.tiles.push(tile);
this.sort();
return groups;
}
return undefined;
}
private tryFormSequence(tile: Tile, pair: boolean): Array<Group> | undefined {
const secondTile = this.find(tile.getFamily(), tile.getValue() - 1) as Tile;
const thirdTile = this.find(tile.getFamily(), tile.getValue() - 2) as Tile;
const groups = this.findGroups(pair);
// Put tiles back
this.tiles.push(secondTile);
this.tiles.push(thirdTile);
this.sort();
if (groups !== undefined) {
groups.push(new Group([thirdTile, secondTile, tile], 0, 0));
this.tiles.push(tile);
this.sort();
return groups;
}
return undefined;
}
}
export class Hand {
private tiles: Array<Tile>;
public isolate: boolean = false;
/**
* Create a hand from string representation
* @param stiles String representation of tiles (e.g., "m1p2s3w1d1")
*/
public constructor(stiles: string = "") {
this.tiles = [];
this.initializeFromString(stiles);
}
/**
* Parse string representation into tiles
*/
private initializeFromString(stiles: string): void {
for (let i = 0; i < stiles.length - 1; i++) {
const tileCode = stiles.substring(i, i + 2);
const type = tileCode[0];
const value = Number(tileCode[1]);
if (this.isValidTileCode(type, value)) {
this.addTileFromCode(type, value);
}
}
}
/**
* Check if tile code is valid
*/
private isValidTileCode(type: string, value: number): boolean {
return (
(type === TILE_TYPES.MANZU.code) ||
(type === TILE_TYPES.PINZU.code) ||
(type === TILE_TYPES.SOUZU.code) ||
(type === TILE_TYPES.WINDS.code) ||
(type === TILE_TYPES.DRAGONS.code)
);
}
/**
* Create a tile from type code and value
*/
private addTileFromCode(type: string, value: number): void {
const familyMap: { [key: string]: number } = {
[TILE_TYPES.MANZU.code]: TILE_TYPES.MANZU.family,
[TILE_TYPES.PINZU.code]: TILE_TYPES.PINZU.family,
[TILE_TYPES.SOUZU.code]: TILE_TYPES.SOUZU.family,
[TILE_TYPES.WINDS.code]: TILE_TYPES.WINDS.family,
[TILE_TYPES.DRAGONS.code]: TILE_TYPES.DRAGONS.family
};
const family = familyMap[type];
if (family !== undefined) {
this.tiles.push(new Tile(family, value, false));
}
}
/**
* Get all tiles in hand
*/
public getTiles(): Array<Tile> {
return this.tiles;
}
/**
* Get number of tiles in hand
*/
public length(): number {
return this.tiles.length;
}
/**
* Add a tile to hand
*/
public push(tile: Tile): void {
this.tiles.push(tile);
}
/**
* Remove and return the last tile
*/
public pop(): Tile | undefined {
return this.tiles.pop();
}
/**
* Find and remove a specific tile by family and value
*/
public find(family: number, value: number): Tile | undefined {
const index = this.findTileIndex(family, value);
if (index !== -1) {
// Swap with first tile and remove
[this.tiles[index], this.tiles[0]] = [this.tiles[0], this.tiles[index]];
const tile = this.tiles.shift();
this.sort();
return tile;
}
return undefined;
}
/**
* Find index of tile with specific family and value
*/
private findTileIndex(family: number, value: number): number {
return this.tiles.findIndex(
tile => tile.getFamily() === family && tile.getValue() === value
);
}
/**
* Remove tile at specific index
*/
public eject(idTile: number): Tile {
if (idTile < 0 || idTile >= this.tiles.length) {
throw new Error("Invalid tile index");
}
// Swap with first tile and remove
[this.tiles[0], this.tiles[idTile]] = [this.tiles[idTile], this.tiles[0]];
const tile = this.tiles.shift();
this.sort();
return tile as Tile;
}
/**
* Get tile at specific index without removing
*/
public get(idTile: number | undefined): Tile | undefined {
if (idTile === undefined || idTile < 0 || idTile >= this.tiles.length) {
return undefined;
}
return this.tiles[idTile];
}
/**
* Sort tiles in ascending order
*/
public sort(): void {
this.tiles.sort((a, b) => a.isLessThan(b) ? -1 : 1);
}
/**
* Count tiles with specific family and value
*/
public count(family: number, value: number): number {
return this.tiles.filter(
tile => tile.getFamily() === family && tile.getValue() === value
).length;
}
/**
* Try to form hand into groups (for winning detection)
* This version preserves the original hand
*/
public toGroup(pair: boolean = false): Array<Group> | undefined {
// Create a helper instance with copied tiles
const groupFinder = new GroupFinder(this.tiles);
return groupFinder.findGroups(pair);
}
/**
* Draw hand tiles on canvas
*/
public drawHand(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
offset: number,
size: number,
focusedTile: number | undefined = undefined,
hidden: boolean = false,
rotation: number = 0,
// If provided, skip drawing this tile index (useful when the focused
// tile is rendered on a dynamic overlay to avoid seeing the unshifted
// tile under the lifted one).
skipIndex?: number
): void {
const tileOffset = (75 + offset) * size;
const offsetX = Math.cos(rotation) * tileOffset;
const offsetY = Math.sin(rotation) * tileOffset;
for (let i = 0; i < this.tiles.length; i++) {
// Optionally skip drawing a specific tile (prevents ghosting when the
// focused tile is drawn separately in a dynamic overlay).
if (skipIndex !== undefined && i === skipIndex) continue;
const isLastAndIsolated = (i === this.tiles.length - 1 && this.isolate) ? 10 : 0;
// Calculate position
let tileX = x + i * offsetX + isLastAndIsolated * size * Math.cos(rotation);
let tileY = y + i * offsetY + isLastAndIsolated * size * Math.sin(rotation);
// Add additional offset for focused tile
if (i === focusedTile) {
tileX += 25 * size * Math.sin(rotation);
tileY -= 25 * size * Math.cos(rotation);
}
// Draw tile
this.tiles[i].drawTile(
ctx,
tileX,
tileY,
size,
hidden,
rotation
);
}
}
/**
* Preload tile images
*/
public async preload(): Promise<void> {
await Promise.all(this.tiles.map(tile => tile.preloadImg()));
}
/**
* Clean up resources
*/
public cleanup(): void {
this.tiles.forEach(tile => tile.cleanup());
this.tiles = [];
}
}

View file

@ -1,125 +0,0 @@
import { Tile } from "./tile"
import { Deck } from "./deck"
const G: number = 100;
const SIZE: number = 1;
const LIMIT_MAX = 1100;
const SPAWN_CHANCE = 0.75;
const INITIAL_Y = -150;
const INITIAL_VY = 50;
const MAX_X = 1000;
const ROTATION_FACTOR = Math.PI;
const MOMENTUM_RANGE = 1;
class FallingTile {
private tile: Tile;
private x: number;
private y: number;
private vy: number = INITIAL_VY;
private orientation: number;
private momentum: number;
public constructor(
tile: Tile,
x: number,
y: number,
orientation: number,
momentum: number
) {
this.tile = tile;
this.x = x;
this.y = y;
this.orientation = orientation;
this.momentum = momentum;
}
public update(dt: number): void {
this.vy += dt * G;
this.y += dt * this.vy;
this.orientation += dt * this.momentum;
}
public isOutside(): boolean {
return this.y > LIMIT_MAX;
}
public getTile(): Tile {
return this.tile;
}
public drawFallingTile(ctx: CanvasRenderingContext2D): void {
this.tile?.drawTile(
ctx,
this.x,
this.y,
SIZE,
false,
this.orientation,
false,
false
);
}
}
export class Rain {
private deck: Deck;
private tiles: FallingTile[] = [];
private tileAddTimer: number = 0;
public constructor() {
this.deck = new Deck(true);
}
public update(dt: number): void {
let i = this.tiles.length;
while (i--) {
const tile = this.tiles[i];
tile.update(dt);
if (tile.isOutside()) {
this.deck.push(tile.getTile());
this.tiles.splice(i, 1);
}
}
this.tileAddTimer += dt;
if (this.tileAddTimer >= (1 / SPAWN_CHANCE)) {
this.addFallingTile();
this.tileAddTimer = 0;
} else if (Math.random() < SPAWN_CHANCE * dt) {
this.addFallingTile();
}
}
public addFallingTile(): void {
if (this.deck.length() === 0) {
return;
}
if (this.deck.length() > 1) {
this.deck.shuffle();
}
const newTile = new FallingTile(
this.deck.pop()!,
Math.floor(Math.random() * MAX_X),
INITIAL_Y,
Math.random() * ROTATION_FACTOR,
(Math.random() * 2 - 1) * MOMENTUM_RANGE
);
this.tiles.push(newTile);
}
public drawRain(ctx: CanvasRenderingContext2D): void {
for (const tile of this.tiles) {
tile.drawFallingTile(ctx);
}
}
public async preloadRain(): Promise<void> {
console.log("preload rain");
await this.deck.preload();
}
}

View file

@ -1,91 +0,0 @@
export function drawState(
ctx: CanvasRenderingContext2D,
turn: number = 0,
rotation: number = 0
): void {
let color = "#e0e0f0";
let r = 30;
let s = 100;
let c = 525;
let pi = Math.PI;
// turn
let w = 150;
let h = 4;
let rd = 2;
let y = 525 + s + 5;
ctx.save();
ctx.translate(525, 525);
ctx.rotate([0, -pi/2, pi, pi/2][turn]);
ctx.translate(-525, -525);
ctx.fillStyle = "#ffcc33";
ctx.beginPath();
ctx.moveTo(c, y);
ctx.lineTo(c - w/2 + rd, y);
ctx.quadraticCurveTo(c - w/2, y, c - w/2, y + rd);
ctx.lineTo(c - w/2, y + h - rd);
ctx.quadraticCurveTo(c - w/2, y + h, c - w/2 + rd, y + h);
ctx.lineTo(c + w/2 - rd, y + h);
ctx.quadraticCurveTo(c + w/2, y + h, c + w/2, y + h - rd);
ctx.lineTo(c + w/2, y + rd);
ctx.quadraticCurveTo(c + w/2, y, c + w/2 - rd, y);
ctx.lineTo(c, y);
ctx.fill();
ctx.stroke();
ctx.restore();
// big rectangle
ctx.save();
ctx.translate(525, 525);
ctx.rotate(rotation);
ctx.translate(-525, -525);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(c - s, c);
ctx.lineTo(c - s, c + s - r);
ctx.quadraticCurveTo(c - s, c + s, c - s + r, c + s);
ctx.lineTo(c + s - r, c + s);
ctx.quadraticCurveTo(c + s, c + s, c + s, c + s - r);
ctx.lineTo(c + s, c - s + r);
ctx.quadraticCurveTo(c + s, c - s, c + s - r, c - s);
ctx.lineTo(c - s + r, c - s);
ctx.quadraticCurveTo(c - s, c - s, c - s, c - s + r);
ctx.lineTo(c - s, c);
ctx.fill();
ctx.stroke();
// winds
ctx.fillStyle = "#000000";
ctx.font = "40px garamond";
ctx.fillText("Est", 505, 515 + s);
ctx.save();
ctx.translate(525, 525);
ctx.rotate(-3.141592/2);
ctx.translate(-525, -525);
ctx.fillText("Sud", 500, 515 + s);
ctx.restore();
ctx.save();
ctx.translate(525, 525);
ctx.rotate(3.141592);
ctx.translate(-525, -525);
ctx.fillText("Ouest", 480, 515 + s);
ctx.restore();
ctx.save();
ctx.translate(525, 525);
ctx.rotate(3.141592/2);
ctx.translate(-525, -525);
ctx.fillText("Nord", 485, 515 + s);
ctx.restore();
ctx.restore();
}

View file

@ -1,562 +0,0 @@
/* ============ CSS Variables ============ */
:root {
/* Colors - Green theme */
--color-primary: #1a7f4b;
--color-primary-light: #22a861;
--color-primary-dark: #0d5c34;
--color-accent: #ffd700;
--color-accent-hover: #ffed4a;
/* Background colors */
--bg-main: linear-gradient(135deg, #0d3d23 0%, #1a5c3a 50%, #0d3d23 100%);
--bg-navbar: linear-gradient(180deg, #1a5c3a 0%, #0d3d23 100%);
--bg-dropdown: rgba(255, 255, 255, 0.98);
--bg-glass: rgba(255, 255, 255, 0.1);
/* Text colors */
--text-light: #ffffff;
--text-dark: #1a1a1a;
--text-muted: #6b7280;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.25);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.3);
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 400ms ease;
/* Typography */
--font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-display: 'Poppins', var(--font-sans);
/* Z-index layers */
--z-dropdown: 100;
--z-navbar: 200;
--z-modal: 300;
--z-tooltip: 400;
}
/* ============ Reset & Base ============ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
background: var(--bg-main);
min-height: 100vh;
color: var(--text-light);
line-height: 1.6;
overflow-x: hidden;
}
/* ============ Typography ============ */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
font-weight: 600;
line-height: 1.3;
}
a {
color: inherit;
text-decoration: none;
transition: color var(--transition-fast);
}
/* ============ Navbar ============ */
.navbar {
position: sticky;
top: 0;
z-index: var(--z-navbar);
background: var(--bg-navbar);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
}
.navbar__container {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.navbar__item {
position: relative;
list-style: none;
}
.navbar__link {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-md) var(--spacing-lg);
font-size: 1rem;
font-weight: 500;
color: var(--text-light);
transition: all var(--transition-normal);
border-radius: var(--radius-md);
position: relative;
}
.navbar__link::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 3px;
background: var(--color-accent);
transition: all var(--transition-normal);
transform: translateX(-50%);
border-radius: var(--radius-full);
}
.navbar__link:hover {
background: var(--bg-glass);
color: var(--color-accent);
}
.navbar__link:hover::after {
width: 60%;
}
.navbar__link--active {
color: var(--color-accent);
}
/* Dropdown icon */
.navbar__link--dropdown::before {
content: '';
position: absolute;
right: var(--spacing-sm);
top: 50%;
transform: translateY(-50%);
border: 5px solid transparent;
border-top-color: currentColor;
transition: transform var(--transition-fast);
}
.navbar__item:hover .navbar__link--dropdown::before {
transform: translateY(-50%) rotate(180deg);
}
/* ============ Dropdown ============ */
.dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: var(--bg-dropdown);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
min-width: 280px;
padding: var(--spacing-sm);
opacity: 0;
visibility: hidden;
transition: all var(--transition-normal);
z-index: var(--z-dropdown);
}
.navbar__item:hover .dropdown {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.dropdown__link {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
color: var(--text-dark);
border-radius: var(--radius-md);
font-size: 0.95rem;
transition: all var(--transition-fast);
}
.dropdown__link:hover {
background: var(--color-primary);
color: var(--text-light);
transform: translateX(5px);
}
.dropdown__link--chapter {
padding-left: var(--spacing-lg);
}
.dropdown__link--chapter::before {
margin-right: var(--spacing-xs);
}
/* ============ Main Container ============ */
.main-container {
display: flex;
justify-content: center;
align-items: flex-start;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
min-height: calc(100vh - 70px);
flex-wrap: nowrap;
overflow-x: auto;
}
/* ============ Canvas Containers ============ */
.canvas-wrapper {
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-xl);
padding: 0;
margin: 0;
box-shadow: var(--shadow-xl);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: transform var(--transition-normal), box-shadow var(--transition-normal);
flex-shrink: 0;
overflow: hidden;
line-height: 0;
font-size: 0;
}
/* Canvas de jeu (gauche) */
.canvas-wrapper:first-child {
order: 1;
}
/* Canvas de texte (droite) */
.canvas-wrapper:last-child {
order: 2;
}
/* ============ Mode Introduction ============ */
.intro-mode {
justify-content: flex-start;
padding-left: var(--spacing-xl);
}
.canvas-wrapper.hidden {
display: none;
}
.canvas-wrapper:hover {
transform: translateY(-2px);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.35);
}
.canvas-wrapper canvas {
display: block;
border-radius: var(--radius-xl);
margin: 0;
padding: 0;
}
/* ============ Buttons ============ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
font-size: 1rem;
font-weight: 600;
font-family: inherit;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn--primary {
background: linear-gradient(135deg, var(--color-primary-light), var(--color-primary));
color: var(--text-light);
box-shadow: var(--shadow-md);
}
.btn--primary:hover {
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn--accent {
background: linear-gradient(135deg, var(--color-accent-hover), var(--color-accent));
color: var(--text-dark);
box-shadow: var(--shadow-md);
}
.btn--accent:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn--danger {
background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
color: var(--text-light);
}
.btn--ghost {
background: transparent;
border: 2px solid var(--text-light);
color: var(--text-light);
}
.btn--ghost:hover {
background: var(--text-light);
color: var(--color-primary-dark);
}
/* ============ Loading Indicator ============ */
.loader {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
.loader__dot {
width: 12px;
height: 12px;
background: var(--color-accent);
border-radius: var(--radius-full);
animation: bounce 1.4s ease-in-out infinite;
}
.loader__dot:nth-child(1) { animation-delay: 0s; }
.loader__dot:nth-child(2) { animation-delay: 0.2s; }
.loader__dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1.2);
opacity: 1;
}
}
/* ============ Tooltips ============ */
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-5px);
background: var(--text-dark);
color: var(--text-light);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: 0.85rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all var(--transition-fast);
z-index: var(--z-tooltip);
}
.tooltip:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* ============ Utility Classes ============ */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mt-lg { margin-top: var(--spacing-lg); }
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }
.hidden { display: none !important; }
.visible { display: block !important; }
/* ============ Animations ============ */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.animate-fadeIn {
animation: fadeIn var(--transition-slow) ease-out;
}
.animate-slideIn {
animation: slideInLeft var(--transition-slow) ease-out;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
/* ============ Scrollbar Styling ============ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-primary-light);
}
/* ============ Responsive Design ============ */
@media (max-width: 2100px) {
.main-container {
flex-wrap: nowrap;
}
}
@media (max-width: 1300px) {
.main-container {
flex-direction: column;
align-items: center;
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
:root {
font-size: 14px;
}
.navbar__container {
flex-wrap: wrap;
justify-content: space-around;
}
.navbar__link {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.9rem;
}
.dropdown {
min-width: 220px;
}
.main-container {
padding: var(--spacing-md);
gap: var(--spacing-md);
}
.canvas-wrapper {
overflow-x: auto;
}
}
@media (max-width: 480px) {
.navbar__container {
gap: 0;
}
.navbar__link {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.8rem;
}
}
/* ============ Print Styles ============ */
@media print {
.navbar,
.btn {
display: none !important;
}
body {
background: white;
color: black;
}
}
/* ============ Focus Styles (Accessibility) ============ */
:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
/* ============ Reduced Motion ============ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,229 +0,0 @@
export async function drawText(
filePath: string,
ctx: CanvasRenderingContext2D,
): Promise<void> {
// New implementation: word-based wrapping, DPR-aware canvas resize,
// simple inline formatting markers: *bold*, ~italic~, #rrggbb{color}...
const raw = await fetch(filePath).then(r => r.text());
const DEFAULT_COLOR = "#ffffff";
const BASE_FONT_SIZE = 30; // in CSS px
const FONT_FAMILY = "Garamond, serif";
const MARGIN_X = 10;
const MARGIN_Y = 10;
const LINE_SPACING = 1.25; // multiplier
const canvas = ctx.canvas;
const dpr = (window.devicePixelRatio || 1);
// Helper to build font string for canvas context
const fontFor = (size: number, bold: boolean, italic: boolean) => {
return `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${size}px ${FONT_FAMILY}`;
};
// Tokenize raw text into words/newlines while preserving inline styles
type Style = { bold: boolean; italic: boolean; color?: string };
type Token = { text: string; style: Style };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
let currentStyle: Style = { bold: false, italic: false };
while (i < input.length) {
const ch = input[i];
if (ch === '*') { currentStyle = { ...currentStyle, bold: !currentStyle.bold }; i++; continue; }
if (ch === '~') { currentStyle = { ...currentStyle, italic: !currentStyle.italic }; i++; continue; }
if (ch === '#') {
// read hex up to '{'
let hex = '#';
i++;
while (i < input.length && input[i] !== '{') { hex += input[i++]; }
if (i < input.length && input[i] === '{') {
// consume '{'
i++;
// read until closing '}' or newline
let inner = '';
while (i < input.length && input[i] !== '}') { inner += input[i++]; }
// consume '}' if present
if (i < input.length && input[i] === '}') i++;
// push inner text as a token with color hex
if (inner.length > 0) {
tokens.push({ text: inner, style: { ...currentStyle, color: hex } });
}
continue;
} else {
// fallback: just treat '#' as normal char
tokens.push({ text: '#', style: { ...currentStyle } });
continue;
}
}
if (ch === '\n') {
tokens.push({ text: '\n', style: { ...currentStyle } });
i++; continue;
}
// read a word (until whitespace or newline)
if (ch === ' ' || ch === '\t' || ch === '\r') {
// collapse sequences of spaces into single space token
let spaces = '';
while (i < input.length && (input[i] === ' ' || input[i] === '\t' || input[i] === '\r')) { spaces += input[i++]; }
tokens.push({ text: ' ', style: { ...currentStyle } });
continue;
}
// regular word
let w = '';
while (i < input.length && input[i] !== ' ' && input[i] !== '\n' && input[i] !== '\t' && input[i] !== '\r') {
// handle formatting markers inside words by breaking
if (input[i] === '*' || input[i] === '~' || input[i] === '#') break;
w += input[i++];
}
if (w.length > 0) tokens.push({ text: w, style: { ...currentStyle } });
}
return tokens;
}
const tokens = tokenize(raw);
// Use an offscreen canvas to measure text widths
const measureCanvas = document.createElement('canvas');
const mctx = measureCanvas.getContext('2d') as CanvasRenderingContext2D;
// Determine max allowed width (allow text to expand but cap to viewport)
const MAX_ALLOWED_CSS_WIDTH = Math.max(200, Math.min(window.innerWidth - 40, 1050));
// Build lines by measuring tokens and wrapping
const lines: Token[][] = [];
let currentLine: Token[] = [];
let currentLineWidth = 0;
let measuredMaxLineWidth = 0;
const spaceWidthCache = new Map<string, number>();
function measureTokenWidth(tok: Token) {
const font = fontFor(BASE_FONT_SIZE, tok.style.bold, tok.style.italic);
mctx.font = font;
return mctx.measureText(tok.text).width;
}
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i];
if (tok.text === '\n') {
// push current line and start a new one
lines.push(currentLine);
measuredMaxLineWidth = Math.max(measuredMaxLineWidth, currentLineWidth);
currentLine = [];
currentLineWidth = 0;
continue;
}
// measure token width
let w = measureTokenWidth(tok);
// treat spaces as small width
if (tok.text === ' ') {
currentLine.push(tok);
currentLineWidth += w;
continue;
}
// If token is wider than the allowed width, try to break it into smaller tokens (characters)
const availableWidth = MAX_ALLOWED_CSS_WIDTH - 2 * MARGIN_X;
if (w > availableWidth) {
// break the token into single-character tokens (preserve style)
for (const ch of tok.text) {
const charTok: Token = { text: ch, style: { ...tok.style } };
const cw = measureTokenWidth(charTok);
if (currentLine.length > 0 && (currentLineWidth + cw) > availableWidth) {
lines.push(currentLine);
measuredMaxLineWidth = Math.max(measuredMaxLineWidth, currentLineWidth);
currentLine = [charTok];
currentLineWidth = cw;
} else {
currentLine.push(charTok);
currentLineWidth += cw;
}
}
continue;
}
// If adding this word would exceed max width, wrap
if (currentLine.length > 0 && (currentLineWidth + w) > availableWidth) {
lines.push(currentLine);
measuredMaxLineWidth = Math.max(measuredMaxLineWidth, currentLineWidth);
currentLine = [tok];
currentLineWidth = w;
} else {
currentLine.push(tok);
currentLineWidth += w;
}
}
// push last line
if (currentLine.length > 0) {
lines.push(currentLine);
measuredMaxLineWidth = Math.max(measuredMaxLineWidth, currentLineWidth);
}
const cssWidth = Math.min(MAX_ALLOWED_CSS_WIDTH, Math.ceil(measuredMaxLineWidth + 2 * MARGIN_X));
const lineHeight = Math.ceil(BASE_FONT_SIZE * LINE_SPACING);
const cssHeight = Math.ceil(lines.length * lineHeight + 2 * MARGIN_Y);
// Resize canvas with DPR awareness. Re-get context after setting width/height (context is reset).
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
canvas.width = Math.max(1, Math.round(cssWidth * dpr));
canvas.height = Math.max(1, Math.round(cssHeight * dpr));
const drawCtx = canvas.getContext('2d') as CanvasRenderingContext2D;
// reset transform and scale for DPR
drawCtx.setTransform(1, 0, 0, 1, 0, 0);
drawCtx.scale(dpr, dpr);
// Do not clear background here — caller may have drawn background. But
// to avoid artifacts on increased canvas size, we can clear the interior area.
drawCtx.clearRect(0, 0, cssWidth, cssHeight);
// Draw lines
let y = MARGIN_Y + BASE_FONT_SIZE; // baseline for first line
for (const lineTokens of lines) {
let x = MARGIN_X;
for (const tok of lineTokens) {
if (tok.text === ' ') {
// measure and advance
drawCtx.font = fontFor(BASE_FONT_SIZE, tok.style.bold, tok.style.italic);
const sw = drawCtx.measureText(' ').width;
x += sw;
continue;
}
drawCtx.font = fontFor(BASE_FONT_SIZE, tok.style.bold, tok.style.italic);
drawCtx.fillStyle = tok.style.color || DEFAULT_COLOR;
drawCtx.fillText(tok.text, x, y);
const w = drawCtx.measureText(tok.text).width;
x += w;
}
y += lineHeight;
}
// update an offscreen accessible copy for screen readers
try {
let htmlCopy = document.getElementById('textHtmlCopy');
if (!htmlCopy) {
htmlCopy = document.createElement('div');
htmlCopy.id = 'textHtmlCopy';
htmlCopy.style.position = 'absolute';
htmlCopy.style.left = '-9999px';
htmlCopy.style.top = 'auto';
htmlCopy.style.width = '1px';
htmlCopy.style.height = '1px';
htmlCopy.style.overflow = 'hidden';
document.body.appendChild(htmlCopy);
}
// plain text variant: strip formatting markers
const plain = raw.replace(/\*|~/g, '').replace(/#([0-9a-fA-F]+)\{([^}]*)\}/g, '$2');
htmlCopy.textContent = plain;
} catch (e) {
// non-fatal
console.warn('Could not create accessible text copy', e);
}
}

View file

@ -1,20 +0,0 @@
import { drawText } from "./parse"
const CANVAS_ID = "myTextCanvas";
const BG_RECT = { x: 0, y: 0, w: 800, h: 1050, color: "#007733" };
const canvas = document.getElementById(CANVAS_ID) as HTMLCanvasElement;
const ctx = canvas.getContext("2d") as NonNullable<CanvasRenderingContext2D>;
canvas.width = BG_RECT.w;
canvas.height = BG_RECT.h;
const number = (window as any).txtNumber;
const path = "src/text/"
ctx.fillStyle = BG_RECT.color;
ctx.fillRect(BG_RECT.x, BG_RECT.y, BG_RECT.w, BG_RECT.h);
const filePath = path + "txt" + number + ".txt";
drawText(filePath, ctx).catch(error => console.error(error));
export {};

View file

@ -1,27 +0,0 @@
~Introduction~
Bienvenue sur ce site dédié au *Riichi Mahjong* !
Ce site s'adresse principalement aux personnes qui découvrent ce
formidable jeu.
Le nombre de chapitres par section peut sembler important
au premier abord, mais chacun d'eux reste court et facile
à aborder.
Vous y trouverez notamment :
- *Initiation au Riichi Mahjong* (onglet #ff00ff{Riichi Mahjong})
- *Explication d'une partie réelle* (onglet #ff00ff{Partie réelle})
pour apprendre à jouer en conditions réelles.
- *Présentation d'une variante* (onglet #ff00ff{Riichi à trois})
- *Entraînement aux différents Yakus* (onglet #ff00ff{Yaku trainer})
- *Introduction au MCR* (variante chinoise) (onglet #ff00ff{MCR})
Ce site est en développement — certaines pages sont encore incomplètes.
N'hésitez pas à poster vos retours sur le dépôt GitHub pour contribuer.

View file

@ -1,17 +0,0 @@
~Chapitre 1: Présentation des tuiles~
Le *Riichi Mahjong* se joue avec des *tuiles*. Elles se divisent en
deux grandes catégories :
- Les #ff00ff{familles}
Le jeu comporte trois familles principales :
- Les #ff00ff{Caractères} (appelées *Man* en japonais)
- Les #ff00ff{Ronds} (appelées *Pin* en japonais)
- Les #ff00ff{Bambous} (appelées *Sou* en japonais)
Chaque famille comprend les nombres de *1* à *9*.
- Les #ff00ff{honneurs}
Ils se divisent en deux catégories :
- Les *Dragons* : #ff0000{Rouge}, *Blanc* et #00ff00{Vert}
- Les *Vents* : #a0a0ff{Est}, #a0a0ff{Sud}, #a0a0ff{Ouest} et #a0a0ff{Nord}

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,24 +0,0 @@
~Chapitre 2: La main~
Durant une partie, un joueur possède généralement *13 tuiles*
dans sa main.
Lors de son tour, il commence par *piocher* (récupérer) une tuile,
puis il *défausse* une tuile (qui peut être celle qu'il vient de
piocher).
~Une main interactive est mise à disposition pour s'entraîner
à cette mécanique~

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,25 +0,0 @@
~Chapitre 3: Les groupes~
Pour gagner, un joueur doit former une main suivant une forme
particulière. En effet, une main standard est composée de :
- *4 groupes* de *3 tuiles* (chaque groupe peut être une suite ou
un brelan)
- *1 paire*
~Cicontre : exemples de groupes valides et non valides~
On distingue notamment :
- Les #ff00ff{Chii} : suites de trois tuiles consécutives (même
famille)
- Les #ff00ff{Pon} : brelans (trois tuiles identiques)
Ainsi, lorsqu'un joueur gagne, sa main contient *14 tuiles* au total.
Il est donc possible d'annoncer la victoire uniquement après avoir
récupéré une tuile (pioche ou défausse).
Lorsque tous ces éléments sont réunis, la main est considérée
#ff00ff{Valide}.
~Pour s'entraîner, piochez et défaussez des tuiles jusqu'à obtenir
une main valide — un message vous indiquera le succès.~

View file

@ -1,27 +0,0 @@
~Chapitre 4: Avoir une main valide en jeu~
Voici votre *première partie* !
Il est temps de mettre en œuvre tout ce que vous avez appris.
Il est important de connaître la mécanique suivante pour obtenir
des tuiles supplémentaires :
- Lorsqu'un joueur défausse une tuile qui complète un *brelan*
pour un autre joueur, ce dernier peut l'annoncer avec
#ff00ff{Pon !} et la prendre pour compléter son brelan.
- Lorsque le joueur précédent défausse une tuile qui complète une
*suite* pour le joueur actif, celui-ci peut l'annoncer avec
#ff00ff{Chii !} et la récupérer pour former la suite.
#ff0000{Attention !}
Cette action est appelée un #ff00ff{appel}. Ce dernier est *définitif* et est
*visible par tout le monde*
Note : si un joueur veut faire un *Chii* en même temps qu'un autre
veut faire un *Pon*, alors le *Pon* a la priorité
~Il est maintenant temps de choisir une tuile à défausser !~

View file

@ -1,24 +0,0 @@
~Chapitre 5: Annoncer la victoire~
Nous avons vu qu'un *Chii* ne peut être déclaré que sur la défausse
du joueur précédent (sauf exception).
Une exception importante : lorsqu'il ne manque plus qu'une seule tuile
pour compléter la main, on dit que le joueur est en #ff00ff{Tenpai}.
Dans cet état, si quelqu'un défausse une tuile qui complète la main
du joueur en Tenpai, ce dernier peut la récupérer et annoncer la
victoire.
Il existe deux façons de gagner :
- Si le joueur en *Tenpai* pioche la tuile gagnante luimême, il
annonce #ff00ff{Tsumo !}
- Si le joueur en *Tenpai* gagne sur la défausse d'un autre joueur,
il annonce #ff00ff{Ron !}
Après une victoire, des commandes apparaîtront pour signaler la
victoire. Les appels de victoire sont #ff00ff{Ron} et
#ff00ff{Tsumo} — ce sont eux qui déclarent la fin de la main.
~Dans l'exemple cicontre, le joueur peut annoncer sa victoire
avec *Ron* sur la défausse du joueur en face en complétant sa
combinaison.~

View file

@ -1,22 +0,0 @@
~Chapitre 6: Le vent du joueur~
Vous avez peutêtre remarqué des indications de direction affichées
au centre. Chaque joueur se voit assigner un vent au début d'une
manche : l'un des joueurs est #ff00ff{Est}, le joueur à sa droite
est #ff00ff{Sud}, puis #ff00ff{Ouest} et #ff00ff{Nord}.
Le #ff00ff{Est} commence la manche. À la fin de la manche, les vents
peuvent tourner (généralement dans le sens antihoraire).
Dans cette démo, la partie s'arrête lorsque *chaque joueur* a été
#ff00ff{Est} au moins une fois.
#ff0000{Remarque} : contrairement à une boussole, l'*Est* et l'*Ouest*
peuvent sembler inversés selon la représentation utilisée ici.
#ff0000{Remarque 2} : si le joueur #ff00ff{Est} remporte la manche,
il peut y avoir une #ff00ff{répétition} (la manche est rejouée) et
les vents ne tournent pas.
~Dans le jeu cicontre, le vent initial est tiré aléatoirement et
change en fonction du résultat de chaque manche.~

View file

@ -1,23 +0,0 @@
~Chapitre 7: Les yakus~
Une main *Valide* (forme correcte) n'est pas suffisante pour gagner :
il faut aussi que la main remplisse au moins une condition de
victoire, appelée un #ff00ff{Yaku}.
Il existe de nombreux Yakus (environ *39*), mais en pratique une
dizaine des plus courants suffit pour la majorité des parties.
Exemples de Yakus fréquents :
- #ff00ff{Tout ordinaire} : aucune tuile terminale ni honneur
- #ff00ff{Brelan de valeur} : par exemple brelan de dragon, brelan
d'Est ou brelan du vent du joueur
- #ff00ff{Main pure} : une seule famille
- #ff00ff{Main semipure} : une seule famille + des honneurs
- #ff00ff{Double suite} : deux suites identiques
#ff0000{Ne fonctionne qu'en main fermée !}
- #ff00ff{Sept paires} : sept paires distinctes
#ff0000{Exception sur la forme des groupes}
(Liste non exhaustive — référezvous à la fiche Yaku pour les règles
détaillées.)

View file

@ -1,19 +0,0 @@
~Chapitre 8 — Le Riichi~
Le #ff00ff{Riichi} est le yaku le plus important du riichi mahjong
(d'où son nom).
Pour le réaliser, la main doit *obligatoirement être fermée*.
Lorsque le joueur est en situation de tenpai, il peut déclarer
#ff00ff{Riichi} ! À ce moment-là, il défausse une tuile (qu'il
placera de côté) et ne pourra plus choisir sa défausse :
- Il *pioche*
- Soit il *gagne*
- Soit il *défausse* la tuile piochée
Cela peut sembler contraignant, mais permet d'obtenir un yaku,
quelle que soit la composition de la main (sous réserve d'être
en tenpai)
~Il est temps d'essayer de déclarer un Riichi !~

View file

@ -1,5 +0,0 @@
Cette page n'a pas *encore* été implémentée.
Merci de votre compréhension et de votre patience.

View file

@ -1,156 +0,0 @@
export class Tile {
private imgFront: HTMLImageElement = new Image();
private imgBack: HTMLImageElement = new Image();
private imgGray: HTMLImageElement = new Image();
private img: HTMLImageElement = new Image();
private imgSrc: string = "";
private tilt: number = 0;
constructor(
private family: number,
private value: number,
private red: boolean
) {
this.setImgSrc();
}
public getFamily(): number {
return this.family;
}
public getValue(): number {
return this.value;
}
public isEqual(family: number, value: number): boolean {
return this.family === family && this.value === value;
}
public isRed(): boolean {
return this.red;
}
public compare(t: Tile): number {
// Compare d'abord par famille, puis par valeur
if (this.family !== t.family) {
return this.family < t.family ? -1 : 1;
}
if (this.value !== t.value) {
return this.value < t.value ? -1 : 1;
}
return 0;
}
public isLessThan(t: Tile): boolean {
return this.family < t.family ||
(this.family === t.family && this.value <= t.value);
}
public setTilt(): void {
this.tilt = (1 - 2 * Math.random()) * 0.04;
}
public drawTile(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
hidden: boolean = false,
rotation: number = 0,
gray: boolean = false,
tilted: boolean = true
): void {
const tileWidth = 75 * size;
const tileHeight = 100 * size;
const halfWidth = tileWidth / 2;
const halfHeight = tileHeight / 2;
const shadowScale = 0.92;
// Sauvegarde du contexte et positionnement
ctx.save();
ctx.translate(x + halfWidth, y + halfHeight);
ctx.rotate(rotation + (tilted ? this.tilt : 0));
// Position de l'ombre (légèrement décalée)
const shadowX = -(tileWidth * shadowScale) / 2;
const shadowY = -(tileHeight * shadowScale) / 2;
// Dessin de l'ombre (commun aux deux cas)
ctx.drawImage(this.imgGray, shadowX, shadowY, tileWidth, tileHeight);
if (hidden) {
// Dessin du dos de la tuile
ctx.drawImage(this.imgBack, -halfWidth, -halfHeight, tileWidth, tileHeight);
} else {
// Dessin de la tuile face visible
ctx.drawImage(this.imgFront, -halfWidth, -halfHeight, tileWidth, tileHeight);
// Dessin du motif sur la tuile (légèrement plus petit)
const patternScale = 0.9;
const patternWidth = tileWidth * patternScale;
const patternHeight = tileHeight * patternScale;
const patternX = -((75 - 7) * size) / 2;
const patternY = -((100 - 10) * size) / 2;
ctx.drawImage(this.img, patternX, patternY, patternWidth, patternHeight);
// Appliquer un filtre gris si demandé
if (gray) {
ctx.drawImage(this.imgGray, -halfWidth, -halfHeight, tileWidth, tileHeight);
}
}
ctx.restore();
}
public cleanup(): void {
// Supprimer tous les gestionnaires d'événements
const images = [this.imgFront, this.imgBack, this.imgGray, this.img];
images.forEach(img => {
img.onload = null;
img.onerror = null;
});
}
public async preloadImg(): Promise<void> {
const imagesToLoad = [
{ img: this.imgFront, src: "img/Regular/Front.svg" },
{ img: this.imgBack, src: "img/Regular/Back.svg" },
{ img: this.imgGray, src: "img/Regular/Gray.svg" },
{ img: this.img, src: this.imgSrc }
];
await Promise.all(
imagesToLoad.map(({ img, src }) => this.loadImg(img, src))
);
}
private loadImg(img: HTMLImageElement, src: string): Promise<void> {
return new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject();
img.src = src;
});
}
private setImgSrc(): void {
this.imgSrc = "img/Regular/";
if (this.family <= 3) {
const families = ["", "Man", "Pin", "Sou"];
this.imgSrc += families[this.family] + String(this.value);
if (this.red) {
this.imgSrc += "-Dora";
}
} else if (this.family === 4) {
const winds = ["", "Ton", "Nan", "Shaa", "Pei"];
this.imgSrc += winds[this.value];
} else if (this.family === 5) {
const dragons = ["", "Chun", "Hatsu", "Haku"];
this.imgSrc += dragons[this.value];
}
this.imgSrc += ".svg";
}
}

View file

@ -1,149 +0,0 @@
/**
* Shared type definitions for the Riichi Mahjong Tutorial
*/
// ============ Canvas Types ============
export interface CanvasContext {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
}
export interface Point {
x: number;
y: number;
}
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
export interface Size {
width: number;
height: number;
}
// ============ Tile Types ============
export type TileFamily = 1 | 2 | 3 | 4 | 5;
export interface TileIdentifier {
family: TileFamily;
value: number;
isRed?: boolean;
}
export type TileFamilyName = 'man' | 'pin' | 'sou' | 'wind' | 'dragon';
export const TILE_FAMILY_MAP: Record<TileFamily, TileFamilyName> = {
1: 'man',
2: 'pin',
3: 'sou',
4: 'wind',
5: 'dragon',
};
// ============ Game Types ============
export type PlayerPosition = 0 | 1 | 2 | 3;
export type WindType = 'east' | 'south' | 'west' | 'north';
export const WIND_ORDER: WindType[] = ['east', 'south', 'west', 'north'];
export interface PlayerState {
position: PlayerPosition;
wind: WindType;
score: number;
isDealer: boolean;
hasRiichi: boolean;
}
export type GamePhase =
| 'waiting'
| 'drawing'
| 'discarding'
| 'calling'
| 'winning'
| 'end';
export interface GameState {
phase: GamePhase;
currentPlayer: PlayerPosition;
roundWind: WindType;
roundNumber: number;
honba: number;
riichiSticks: number;
}
// ============ Group Types ============
export type GroupType = 'pair' | 'triplet' | 'sequence' | 'quad';
export interface GroupDefinition {
type: GroupType;
tiles: TileIdentifier[];
isOpen: boolean;
}
// ============ Yaku Types ============
export interface YakuResult {
name: string;
han: number;
isYakuman?: boolean;
}
export interface ScoreResult {
yakus: YakuResult[];
han: number;
fu: number;
basePoints: number;
totalPoints: number;
}
// ============ UI Types ============
export type ButtonAction =
| 'pass'
| 'chii'
| 'pon'
| 'kan'
| 'ron'
| 'tsumo'
| 'riichi';
export interface ButtonState {
action: ButtonAction;
enabled: boolean;
highlighted?: boolean;
}
// ============ Animation Types ============
export interface AnimationConfig {
duration: number;
easing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
delay?: number;
}
export interface AnimationState {
startTime: number;
progress: number;
isComplete: boolean;
}
// ============ Event Types ============
export interface TileClickEvent {
tileIndex: number;
tile: TileIdentifier;
position: Point;
}
export interface GameEvent {
type: string;
player: PlayerPosition;
data?: unknown;
timestamp: number;
}
// ============ Callback Types ============
export type CleanupFunction = () => void;
export type RenderFunction = (deltaTime: number) => void;
export type UpdateFunction = (deltaTime: number) => void;

View file

@ -1,335 +0,0 @@
/**
* Utility functions for the Riichi Mahjong Tutorial
*/
import type { AnimationConfig, AnimationState, Point, Rectangle } from '../types';
// ============ Canvas Utilities ============
/**
* Get a canvas context with optimizations
*/
export function getOptimizedContext(
canvas: HTMLCanvasElement,
alpha: boolean = false,
): CanvasRenderingContext2D | null {
return canvas.getContext('2d', {
alpha,
desynchronized: true,
willReadFrequently: false,
});
}
/**
* Clear a canvas with a solid color
*/
export function clearCanvas(
ctx: CanvasRenderingContext2D,
color: string,
width: number,
height: number,
): void {
ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height);
}
/**
* Create an offscreen canvas for double buffering
*/
export function createOffscreenCanvas(
width: number,
height: number,
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = getOptimizedContext(canvas);
if (!ctx) {
return null;
}
return { canvas, ctx };
}
// ============ Math Utilities ============
/**
* Clamp a value between min and max
*/
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Linear interpolation
*/
export function lerp(start: number, end: number, t: number): number {
return start + (end - start) * clamp(t, 0, 1);
}
/**
* Convert degrees to radians
*/
export function degToRad(degrees: number): number {
return degrees * (Math.PI / 180);
}
/**
* Convert radians to degrees
*/
export function radToDeg(radians: number): number {
return radians * (180 / Math.PI);
}
/**
* Get a random number between min and max
*/
export function randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
/**
* Get a random integer between min and max (inclusive)
*/
export function randomInt(min: number, max: number): number {
return Math.floor(randomRange(min, max + 1));
}
// ============ Geometry Utilities ============
/**
* Check if a point is inside a rectangle
*/
export function pointInRect(point: Point, rect: Rectangle): boolean {
return (
point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height
);
}
/**
* Get the distance between two points
*/
export function distance(p1: Point, p2: Point): number {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Normalize an angle to be between 0 and 2π
*/
export function normalizeAngle(angle: number): number {
const TWO_PI = Math.PI * 2;
return ((angle % TWO_PI) + TWO_PI) % TWO_PI;
}
// ============ Animation Utilities ============
/**
* Easing functions
*/
export const Easing = {
linear: (t: number): number => t,
easeIn: (t: number): number => t * t,
easeOut: (t: number): number => t * (2 - t),
easeInOut: (t: number): number =>
t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeInCubic: (t: number): number => t * t * t,
easeOutCubic: (t: number): number => (--t) * t * t + 1,
easeInOutCubic: (t: number): number =>
t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
easeOutBounce: (t: number): number => {
if (t < 1 / 2.75) {
return 7.5625 * t * t;
} else if (t < 2 / 2.75) {
return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
} else if (t < 2.5 / 2.75) {
return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
} else {
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
},
};
/**
* Create an animation state
*/
export function createAnimation(config: AnimationConfig): AnimationState & { config: AnimationConfig } {
return {
config,
startTime: 0,
progress: 0,
isComplete: false,
};
}
/**
* Update an animation state
*/
export function updateAnimation(
state: AnimationState & { config: AnimationConfig },
currentTime: number,
): void {
if (state.startTime === 0) {
state.startTime = currentTime + (state.config.delay ?? 0);
}
const elapsed = currentTime - state.startTime;
state.progress = clamp(elapsed / state.config.duration, 0, 1);
state.isComplete = state.progress >= 1;
}
// ============ Array Utilities ============
/**
* Shuffle an array in place (Fisher-Yates)
*/
export function shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = randomInt(0, i);
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* Remove an item from an array by index
*/
export function removeAt<T>(array: T[], index: number): T | undefined {
if (index >= 0 && index < array.length) {
return array.splice(index, 1)[0];
}
return undefined;
}
/**
* Get unique values from an array
*/
export function unique<T>(array: T[]): T[] {
return [...new Set(array)];
}
// ============ DOM Utilities ============
/**
* Get element by ID with type safety
*/
export function getElementById<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
/**
* Create an element with attributes
*/
export function createElement<K extends keyof HTMLElementTagNameMap>(
tag: K,
attributes?: Partial<HTMLElementTagNameMap[K]>,
): HTMLElementTagNameMap[K] {
const element = document.createElement(tag);
if (attributes) {
Object.assign(element, attributes);
}
return element;
}
// ============ Image Utilities ============
/**
* Load an image as a Promise
*/
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
/**
* Preload multiple images
*/
export async function preloadImages(sources: string[]): Promise<HTMLImageElement[]> {
return Promise.all(sources.map(loadImage));
}
// ============ Time Utilities ============
/**
* Create a debounced function
*/
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => fn(...args), delay);
};
}
/**
* Create a throttled function
*/
export function throttle<T extends (...args: unknown[]) => void>(
fn: T,
limit: number,
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => { inThrottle = false; }, limit);
}
};
}
/**
* Wait for a specified duration
*/
export function wait(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ============ Storage Utilities ============
/**
* Safe localStorage get with JSON parsing
*/
export function getStorageItem<T>(key: string, defaultValue: T): T {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) as T : defaultValue;
} catch {
return defaultValue;
}
}
/**
* Safe localStorage set with JSON stringify
*/
export function setStorageItem<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage might be full or disabled
console.warn(`Failed to save to localStorage: ${key}`);
}
}

View file

@ -1,328 +0,0 @@
import { Hand } from "../hand"
import { Deck } from "../deck"
import { Tile } from "../tile";
export const function_generator = {
ordinaires(wind: number = 0): Hand {
let h = new Hand();
let deck = new Deck();
deck.shuffle();
// choix d'une paire
let t1 = deck.pop();
let t2 = deck.find(t1.getFamily(), t1.getValue()) as NonNullable<Tile>;
h.push(t1);
h.push(t2);
let count = 2;
while (count !== 14) {
if (Math.random() < 0.5) { // pon
let t1 = deck.pop();
let f = t1.getFamily();
let v = t1.getValue();
if (
f < 4 && v !== 1 && v !== 9 &&
deck.count(f, v) > 1
) {
let t2 = deck.find(f, v) as NonNullable<Tile>;
let t3 = deck.find(f, v) as NonNullable<Tile>;
h.push(t1);
h.push(t2);
h.push(t3);
count += 3;
} else {
deck.push(t1);
deck.shuffle();
}
} else { // chii
let t1 = deck.pop();
let f = t1.getFamily();
let v = t1.getValue();
if (
f < 4 && v !== 1 && v < 6 &&
deck.count(f, v + 1) > 0 &&
deck.count(f, v + 2) > 0
) {
let t2 = deck.find(f, v + 1) as NonNullable<Tile>;
let t3 = deck.find(f, v + 2) as NonNullable<Tile>;
h.push(t1);
h.push(t2);
h.push(t3);
count += 3;
} else {
deck.push(t1);
deck.shuffle();
}
}
}
h.sort();
return h;
},
brelan_valeur(wind: number = 0): Hand {
let h = new Hand();
let deck = new Deck();
deck.shuffle();
// brelan
let f: number;
let v: number;
if (Math.random() < 1/3) { // dragons
f = 5;
v = Math.floor(Math.random() * 3) + 1;
} else if (Math.random() < 0.5) { // est
f = 4;
v = 1;
} else { // vent du joueur
f = 4;
v = wind + 1;
}
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
let count = 3;
// pair
f = Math.floor(Math.random() * 3) + 1;
v = Math.floor(Math.random() * 9) + 1;
while (deck.count(f, v) < 2) {
f = Math.floor(Math.random() * 3) + 1;
v = Math.floor(Math.random() * 9) + 1;
}
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
count += 2;
while (count !== 14) {
if (Math.random() < 0.5) { // pon
let t1 = deck.pop();
let f = t1.getFamily();
let v = t1.getValue();
if (
deck.count(f, v) > 1
) {
h.push(t1);
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
count += 3;
} else {
deck.push(t1);
deck.shuffle();
}
} else { // chii
let f = Math.floor(Math.random() * 3) + 1;
let v = Math.floor(Math.random() * 7) + 1;
if (
deck.count(f, v) > 1 &&
deck.count(f, v + 1) > 1 &&
deck.count(f, v + 2) > 1
) {
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v + 1) as NonNullable<Tile>);
h.push(deck.find(f, v + 2) as NonNullable<Tile>);
count += 3;
}
}
}
h.sort();
return h;
},
main_pure(wind: number = 0): Hand {
let h = new Hand();
let deck = new Deck();
deck.shuffle();
let f = Math.floor(Math.random() * 3) + 1;
let v = Math.floor(Math.random() * 9) + 1;
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
let count = 2;
while (count < 14) {
if (Math.random() < 0.5) { // pon
let v = Math.floor(Math.random() * 9) + 1;
if (deck.count(f, v) > 2) {
for (let i = 0; i < 3; i++) {
h.push(deck.find(f, v) as NonNullable<Tile>);
count++;
}
}
} else { // chii
let v = Math.floor(Math.random() * 7) + 1;
if (
deck.count(f, v) > 0 &&
deck.count(f, v+1) > 0 &&
deck.count(f, v+2) > 0
) {
for (let i = 0; i < 3; i++) {
h.push(deck.find(f, v + i) as NonNullable<Tile>);
count++;
}
}
}
}
h.sort();
return h;
},
main_semi_pure(wind: number = 0): Hand {
let h = new Hand();
let deck = new Deck();
deck.shuffle();
let f = Math.floor(Math.random() * 3) + 1;
let v = Math.floor(Math.random() * 9) + 1;
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
let count = 2;
let family = Math.floor(Math.random() * 2) + 4;
let value = Math.floor(Math.random() * 3) + 1;
h.push(deck.find(family, value) as NonNullable<Tile>);
h.push(deck.find(family, value) as NonNullable<Tile>);
h.push(deck.find(family, value) as NonNullable<Tile>);
count += 3;
while (count < 14) {
if (Math.random() < 0.5) { // pon
if (Math.random() < 0.5) { // famille
let v = Math.floor(Math.random() * 9) + 1;
if (deck.count(f, v) > 2) {
for (let i = 0; i < 3; i++) {
h.push(deck.find(f, v) as NonNullable<Tile>);
count++;
}
}
} else if (Math.random() < 0.5) { // vent
let v = Math.floor(Math.random() * 4) + 1;
if (deck.count(4, v) > 2) {
for (let i = 0; i < 3; i++) {
h.push(deck.find(4, v) as NonNullable<Tile>);
count++;
}
}
} else { // dragon
let v = Math.floor(Math.random() * 3) + 1;
if (deck.count(5, v) > 2) {
for (let i = 0; i < 3; i++) {
h.push(deck.find(5, v) as NonNullable<Tile>);
count++;
}
}
}
} else { // chii
let v = Math.floor(Math.random() * 7) + 1;
if (
deck.count(f, v) > 0 &&
deck.count(f, v+1) > 0 &&
deck.count(f, v+2) > 0
) {
for (let i = 0; i < 3; i++) {
h.push(deck.find(f, v + i) as NonNullable<Tile>);
count++;
}
}
}
}
h.sort();
return h;
},
double_suite(wind: number = 0): Hand {
let h = new Hand();
let deck = new Deck();
deck.shuffle();
// choix d'une paire
let t1 = deck.pop();
let t2 = deck.find(t1.getFamily(), t1.getValue()) as NonNullable<Tile>;
h.push(t1);
h.push(t2);
let count = 2;
// double suite
let f = Math.floor(Math.random() * 3) + 1;
let v = Math.floor(Math.random() * 7) + 1;
while (
deck.count(f, v) < 2 ||
deck.count(f, v + 1) < 2 ||
deck.count(f, v + 2 ) < 2
) {
f = Math.floor(Math.random() * 3) + 1;
v = Math.floor(Math.random() * 7) + 1;
}
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v + 1) as NonNullable<Tile>);
h.push(deck.find(f, v + 1) as NonNullable<Tile>);
h.push(deck.find(f, v + 2) as NonNullable<Tile>);
h.push(deck.find(f, v + 2) as NonNullable<Tile>);
count += 6;
while (count !== 14) {
if (Math.random() < 0.5) { // pon
let t1 = deck.pop();
let f = t1.getFamily();
let v = t1.getValue();
if (
deck.count(f, v) > 1
) {
h.push(t1);
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v) as NonNullable<Tile>);
count += 3;
} else {
deck.push(t1);
deck.shuffle();
}
} else { // chii
let f = Math.floor(Math.random() * 3) + 1;
let v = Math.floor(Math.random() * 7) + 1;
if (
deck.count(f, v) > 1 &&
deck.count(f, v + 1) > 1 &&
deck.count(f, v + 2) > 1
) {
h.push(deck.find(f, v) as NonNullable<Tile>);
h.push(deck.find(f, v + 1) as NonNullable<Tile>);
h.push(deck.find(f, v + 2) as NonNullable<Tile>);
count += 3;
}
}
}
h.sort();
return h;
},
sept_pairs(wind: number = 0): Hand {
let h = new Hand();
let deck = new Deck();
deck.shuffle();
let count = 0;
while (count !== 7) {
let tile = deck.pop();
if (h.getTiles().every(
t => t.compare(tile) !== 0
)) {
count++;
h.push(tile);
h.push(new Tile(tile.getFamily(), tile.getValue(), false));
}
}
h.sort();
return h;
}
}

View file

@ -1,889 +0,0 @@
import { Hand } from "../hand";
import { Group } from "../group";
import { Tile } from "../tile"
function ord(g: Group): boolean {
return g.getTiles().every(
t => t.getFamily() < 4 &&
t.getValue() > 1 &&
t.getValue() < 9
)
}
function term(g: Group): boolean {
return g.getTiles().every(
t =>
t.getFamily() < 4 &&
(t.getValue() === 1 ||
t.getValue() === 9)
);
}
function honn(g: Group): boolean {
return g.getTiles().every(
t =>
t.getFamily() >= 4
);
}
function chii(g: Group): boolean {
let t = g.getTiles();
return t[0].getValue() !== t[1].getValue();
}
function pon(g: Group): boolean {
let t = g.getTiles();
return t[0].getValue() === t[1].getValue();
}
function green(t: Tile): boolean {
let f = t.getFamily();
let v = t.getValue();
if (f !== 5 && f !== 3) {
return false;
}
if (f === 5) {
return v === 2;
}
return v % 2 === 0 || v === 3;
}
export const yakus = {
/**
* double suite pure
* 0/1
*/
lipekou: function (
hand: Hand,
groups: Array<Group>,
wind: number
): number {
if (groups.length > 0 && !yakus.ryanpeikou(hand, groups, wind)) { // ouvert
return 0;
}
let gr = hand.toGroup() as NonNullable<Array<Group>>;
gr.sort((g1, g2) => g1.compare(g2));
for (let i = 0; i < 3; i++) {
let g1 = gr[i].getTiles();
let g2 = gr[i+1].getTiles();
if (
g1[0].isEqual(g2[0].getFamily(), g2[0].getValue()) &&
chii(gr[i]) && chii(gr[i+1])
) {
return 1;
}
}
return 0;
},
/**
* deux doubles suites pures
* 0/3
*/
ryanpeikou: function (
hand: Hand,
groups: Array<Group>,
wind: number
): number {
if (groups.length > 0) {
return 0;
}
let gr = hand.toGroup() as NonNullable<Array<Group>>;
gr.filter(g => chii(g));
if (gr.length < 4) { // pas assez de suite
return 0;
}
gr.sort((g1, g2) => g1.compare(g2));
let t1 = gr[0].getTiles()[0];
let t2 = gr[1].getTiles()[0];
let t3 = gr[2].getTiles()[0];
let t4 = gr[3].getTiles()[0];
if (
t1.compare(t2) === 0 &&
t3.compare(t4) === 0
) {
return 3;
}
return 0;
},
/**
* tout suite
* 0/1
*/
pinfu: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number { //TODO: double attente
if (groups.length > 0) {
return 0;
}
let h = hand.toGroup();
if (
h !== undefined &&
h.every(
g => {
let tiles = g.getTiles();
return (chii(g) || tiles.length === 2)
}
)
) {
return 1;
}
return 0;
},
/**
* triple suite
* 1/2
*/
sanshokuDoujun: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
let gr = [];
if (h !== undefined) {
gr = groups.concat(h);
} else {
gr = groups;
}
gr = gr.filter(g => chii(g));
gr.sort((g1, g2) => g1.compare(g2))
if (gr.length < 3) { // pas assez de chii
return 0;
} else if(gr.length === 3) {
let t0 = gr[0].getTiles();
let t1 = gr[1].getTiles();
let t2 = gr[2].getTiles();
if (
t0[0].getValue() === t1[0].getValue() &&
t0[0].getValue() === t2[0].getValue() &&
t0[0].getFamily() !== t1[0].getFamily() &&
t0[0].getFamily() !== t2[0].getFamily() &&
t1[0].getFamily() !== t2[0].getFamily()
) {
return groups.length > 0 ? 1 : 2;
}
return 0;
} else {// il y a un intrus
for (let i = 0; i < 4; i++) {
let index = []
for (let j = 0; j < 4; j++) {
if (j !== i) {
index.push(j);
}
}
let t0 = gr[index[0]].getTiles();
let t1 = gr[index[1]].getTiles();
let t2 = gr[index[2]].getTiles();
if (
t0[0].getValue() === t1[0].getValue() &&
t0[0].getValue() === t2[0].getValue() &&
t0[0].getFamily() !== t1[0].getFamily() &&
t0[0].getFamily() !== t2[0].getFamily() &&
t1[0].getFamily() !== t2[0].getFamily()
) {
return groups.length > 0 ? 1 : 2;
}
}
return 0;
}
},
/**
* grande suite pure
* 1/2
*/
ittsuu: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(g => chii(g));
gr.sort((g1, g2) => g1.compare(g2));
if (gr.length < 3) { // trop peu de suite
return 0;
} else if (gr.length === 3) { // pile le bon nombre
let g1 = gr[0].getTiles();
let g2 = gr[1].getTiles();
let g3 = gr[2].getTiles();
if (
g1[0].getFamily() === g2[0].getFamily() &&
g2[0].getFamily() === g3[0].getFamily() &&
g1[0].getValue() === 1 &&
g2[0].getValue() === 4 &&
g3[0].getValue() === 7
) {
return groups.length > 0 ? 1 : 2;
}
} else { // il y a un intrus
for (let i = 0; i < 4; i++) {
let index = []
for (let j = 0; j < 4; j++) {
if (j !== i) {
index.push(j);
}
}
let t0 = gr[index[0]].getTiles();
let t1 = gr[index[1]].getTiles();
let t2 = gr[index[2]].getTiles();
if (
t0[0].getValue() === 1 &&
t1[0].getValue() === 4 &&
t2[0].getValue() === 7 &&
t0[0].getFamily() === t1[0].getFamily() &&
t0[0].getFamily() === t2[0].getFamily()
) {
return groups.length > 0 ? 1 : 2;
}
}
return 0;
}
return 0;
},
/**
* tout ordinaire
* 1/1
*/
tanyao: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
if (h === undefined) {
return 0;
}
if (
groups.every(g => ord(g)) &&
h.every(g => ord(g))
) {
return 1;
}
return 0;
},
/**
* brelan de valeur
* 1/1
*/
yakuhai: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(g => pon(g) && g.getTiles().length === 3);
let han = 0;
gr.forEach(
g => {
let t = g.getTiles();
let f = t[0].getFamily();
let v = t[0].getValue();
if (f === 5) { // brelan de dragon
han++;
}
if (f === 4 && v === 0) { // vent d'est
han++;
}
if (f === 4 && v === wind) { // vent du joueur
han++;
}
}
);
return han;
},
/**
* trois petits dragons
* 2/2
*/
shousangen: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(g => pon(g));
let nbPon = 0;
let nbPair = 0;
gr.forEach(
g => {
let t = g.getTiles();
if (t[0].getFamily() === 5) {
if (t.length === 3) {
nbPon++;
} else {
nbPair++;
}
}
}
)
if (nbPon == 2 && nbPair == 1) {
return 2;
}
return 0;
},
/**
* trois grands dragons
* 13/13
*/
daisangen: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(
g => pon(g) &&
g.getTiles().length === 3 &&
g.getTiles()[0].getFamily() === 5
);
if (gr.length === 3) {
return 13;
}
return 0;
},
/**
* quatre petits vents
* 13/13
*/
shousuushii: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(g => pon(g));
let nbPon = 0;
let nbPair = 0;
gr.forEach(
g => {
let t = g.getTiles();
if (t[0].getFamily() === 4) {
if (t.length === 3) {
nbPon++;
} else {
nbPair++;
}
}
}
)
if (nbPon == 3 && nbPair == 1) {
return 13;
}
return 0;
},
/**
* quatre grands vents
* 13/13
*/
daisuushi: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(g => pon(g));
let nbPon = 0;
let nbPair = 0;
gr.forEach(
g => {
let t = g.getTiles();
if (t[0].getFamily() === 4) {
if (t.length === 3) {
nbPon++;
} else {
nbPair++;
}
}
}
)
if (nbPon == 4 && nbPair == 0) {
return 13;
}
return 0;
},
/**
* terminales et honneurs partout
* 1/2
*/
chanta: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(
g => {
let tiles = g.getTiles();
let f = tiles[0].getFamily();
let v = tiles[0].getValue();
let vd = tiles[tiles.length - 1].getValue();
return f < 4 && v !== 1 && vd !== 9
}
);
if (gr.length > 0) {
return 0;
}
return groups.length > 0 ? 1 : 2;
},
/**
* terminales partout
* 2/3
*/
junchan: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(
g => {
let tiles = g.getTiles();
let f = tiles[0].getFamily();
let v = tiles[0].getValue();
let vd = tiles[tiles.length - 1].getValue();
return f > 3 || (v !== 1 && vd !== 9)
}
);
if (gr.length > 0) {
return 0;
}
return groups.length > 0 ? 2 : 3;
},
/**
* tout terminale et honneur
* 2/2
*/
honroutou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
gr = gr.filter(
g => {
let tiles = g.getTiles();
let f = tiles[0].getFamily();
let v = tiles[1].getValue();
return f < 4 && v !== 1 && v !== 9
}
);
if (gr.length > 0) {
return 0;
}
return 2;
},
/**
* tout terminale
* 13/13
*/
chinroutou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let gr = groups.concat(hand.toGroup() as NonNullable<Array<Group>>);
if (
gr.every(g => term(g))
) {
return 13;
}
return 0;
},
/**
* tout honneur
* 13/13
*/
tsuuiisou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
if (h === undefined) {
return 0;
}
if (
groups.every(g => honn(g)) &&
h.every(g => honn(g))
) {
return 13;
}
return 0;
},
/**
* treize orphelins
* 0/13
*/
kokushiMusou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
if (groups.length > 0) {
return 0;
}
let h = hand.getTiles();
if (
h.some(t =>
t.getFamily() < 4 &&
t.getValue() > 1 &&
t.getValue() < 9
)
) {
return 0;
}
let count = 0;
for (let i = 0; i < h.length - 1; i++) {
if (h[i].isEqual(h[i+1].getFamily(), h[i+1].getValue())) {
count++;
}
if (count > 1) {
break;
}
}
if (count === 1) {
return 13;
}
return 0;
},
/**
* sept paires
* 0/2
*/
chiitoitsu: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
if (groups.length > 0) {
return 0;
}
let h = hand.getTiles();
if (h.length !== 14) {
return 0;
}
for (let i = 0; i < 14; i += 2) {
if (
h[i].compare(h[i+1]) !== 0 ||
(i > 0 && h[i].compare(h[i-1]) === 0)
) {
return 0
}
}
return 2;
},
/**
* tout brelans
* 2/2
*/
toitoi: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
if (
groups.every(g => pon(g)) &&
h?.every(g => pon(g))
) {
return 2;
}
return 0;
},
/**
* trois brelan cachés
* 2/2
*/
sanankou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
let count = 0;
h?.forEach(
g => {
if (pon(g) && g.getTiles().length === 3) {
count++;
}
}
);
if (count === 3) {
return 2;
}
return 0;
},
/**
* quatre brelans cachés
* 0/13
*/
suuankou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
let count = 0;
h?.forEach(
g => {
if (pon(g) && g.getTiles().length === 3) {
count++;
}
}
);
if (count === 4) {
return 13;
}
return 0;
},
/**
* triple brelan
* 2/2
*/
sanshokuDoukou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
let gr = [];
if (h !== undefined) {
gr = groups.concat(h);
} else {
gr = groups;
}
gr = gr.filter(g => pon(g) && g.getTiles().length === 3);
gr.sort((g1, g2) => g1.compare(g2))
if (gr.length < 3) { // pas assez de chii
return 0;
} else if(gr.length === 3) {
let t0 = gr[0].getTiles();
let t1 = gr[1].getTiles();
let t2 = gr[2].getTiles();
if (
t0[0].getValue() === t1[0].getValue() &&
t0[0].getValue() === t2[0].getValue() &&
t0[0].getFamily() !== t1[0].getFamily() &&
t0[0].getFamily() !== t2[0].getFamily() &&
t1[0].getFamily() !== t2[0].getFamily()
) {
return groups.length > 0 ? 1 : 2;
}
return 0;
} else {// il y a un intrus
for (let i = 0; i < 4; i++) {
let index = []
for (let j = 0; j < 4; j++) {
if (j !== i) {
index.push(j);
}
}
let t0 = gr[index[0]].getTiles();
let t1 = gr[index[1]].getTiles();
let t2 = gr[index[2]].getTiles();
if (
t0[0].getValue() === t1[0].getValue() &&
t0[0].getValue() === t2[0].getValue() &&
t0[0].getFamily() !== t1[0].getFamily() &&
t0[0].getFamily() !== t2[0].getFamily() &&
t1[0].getFamily() !== t2[0].getFamily()
) {
return groups.length > 0 ? 1 : 2;
}
}
return 0;
}
},
/**
* trois carrés
* 2/2
*/
sankantsu: function( //TODO
hand: Hand,
groups: Array<Group>,
wind: number
): number {
return 0;
},
/**
* quatre carrés
* 13/13
*/
suukantsu: function( //TODO
hand: Hand,
groups: Array<Group>,
wind: number
): number {
return 0;
},
/**
* semie pure
* 2/3
*/
honitsu: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.toGroup();
let gr = [];
if (h !== undefined) {
gr = groups.concat(h);
} else {
gr = groups;
}
gr.sort((g1, g2) => g1.compare(g2))
if (gr.length === 0) {
return 0;
}
if (gr[gr.length - 1].getTiles()[0].getFamily() < 4) { // main pure
return 0;
}
let f = gr[0].getTiles()[0].getFamily();
if (f > 3) { // tout honneur
return 0;
}
if (gr.every(
g => {
let ff = g.getTiles()[0].getFamily();
return ff > 3 || f === ff
}
)) {
return groups.length > 0 ? 2 : 3;
}
return 0;
},
/**
* main pure
* 5/6
*/
chinitsu: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
let h = hand.getTiles();
let t0 = h[0];
if (
h.every(t => t.getFamily() === t0.getFamily()) &&
groups.every(g => g.getTiles().every(t => t.getFamily() === t0.getFamily()))
) {
return groups.length > 0 ? 5 : 6;
}
return 0;
},
/**
* main verte
* 13/13
*/
ryuuisou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
if (groups.length > 0) {
return 0;
}
let h = hand.toGroup();
if (h?.every(
g => g.getTiles().every(
t => green(t)
)
)) {
return 13;
}
return 0;
},
/**
* neuf portes
* 0/13
*/
chuurenPoutou: function(
hand: Hand,
groups: Array<Group>,
wind: number
): number {
if (groups.length > 0 || yakus.chinitsu(hand, groups, wind) === 0) {
return 0;
}
let tiles = hand.getTiles();
if (tiles[0].getFamily() >= 4) {
return 0;
}
let pureHand = [tiles[0].getValue()];
let count = 1;
for (let i = 1; i < tiles.length; i++) {
let v = tiles[i].getValue();
let lastv = pureHand[pureHand.length - 1];
if (v === 1 || v === 9) {
if (v === lastv) {
count++;
if (count < 4) {
pureHand.push(v);
} else if (count > 4) {
return 0;
}
} else {
count = 1;
pureHand.push(v);
}
} else {
if (v === lastv) {
count ++;
if (count < 2) {
pureHand.push(v);
} else if (count > 2) {
return 0;
}
} else {
count = 1;
pureHand.push(v);
}
}
}
if (pureHand.toString() === [1, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9].toString()) {
return 13;
}
return 0;
}
}

View file

@ -1,9 +0,0 @@
export function assert(b: boolean, msg: string): number {
if (b) {
console.log("%c[SUCCES] " + msg, "color: green");
return 1;
} else {
console.log("%c[ECHEC] " + msg, "color: red");
return 0;
}
}

View file

@ -1,2 +0,0 @@
import "./test_hand"
import "./test_yakus"

View file

@ -1,20 +0,0 @@
import { Hand } from "../src/hand"
import { assert } from "./assert";
let h0 = new Hand("s1s1");
let h1 = new Hand("m1m2m3");
let h2 = new Hand("m2m2m2 p1p1");
let h3 = new Hand("m1m2m3 p1p2p3 s1s2s3 m9m9m9 p9p9");
let h4 = new Hand("m2m2m2 p1p1p1");
let count = 0;
let total = 0;
count += assert(h0.toGroup()?.length === 1, "s11 has 1 group");
count += assert(h1.toGroup()?.length === 1, "m123 has 1 group");
count += assert(h2.toGroup()?.length === 2, "m222 p11 has 2 groups");
count == assert(h3.toGroup()?.length === 5, "m123 p123 s123 m999 p99 has 5 groups");
count += assert(h4.toGroup()?.length === 2, "m222 p111 has 2 groups");
total += 4;
console.log("Succès: " + count.toString() + "/" + total.toString());

View file

@ -1,203 +0,0 @@
import { assert } from "./assert"
import { yakus } from "../src/yakus/yaku"
import { Hand } from "../src/hand"
import { Group } from "../src/group";
import { Tile } from "../src/tile";
let count = 0;
let total = 0;
let h1 = new Hand("m1m2m3 m4m5m6 m7m8m9 m1m2m3 m5m5");
let h2 = new Hand("m1m1m1 m4m4m4 m7m7m7 p1p1p1 p5p5");
let h3 = new Hand("m1m1m1 p9p9p9 s1s1s1 w2w2w2 d1d1");
let h4 = new Hand("m2m3m4 p3p3p3 p4p5p6 s4s4 s6s7s8");
let h5 = new Hand("m1m2m3 m1m2m3 p7p8p9 p7p8p9 w3w3");
let h6 = new Hand("m1m2m3 m1m2m3 p6p7p8 p7p8p9 w3w3");
let h7 = new Hand("m1m2m3 p1p2p3 s1s2s3 m7m8m9 p9p9");
let h8 = new Hand("m1m2m3 m1m2m3 p6p6 w0w0w0 w3w3w3");
let h9 = new Hand("m1m2m3 m1m2m3 d1d1d1 d2d2d2 d3d3");
let h10 = new Hand("m1m2m3 p6p6 d1d1d1 d2d2d2 d3d3d3");
let h11 = new Hand("m1m2m3 w1w1w1 w2w2w2 w3w3w3 w4w4");
let h12 = new Hand("m1m1 w1w1w1 w2w2w2 w3w3w3 w4w4w4");
let h13 = new Hand("d1d1 w1w1w1 w2w2w2 w3w3w3 w4w4w4");
let h14 = new Hand("m1m1 m9m9m9 p1p1p1 p9p9p9 s1s1s1");
let h15 = new Hand("m1m9m9 p1p9 s1s9 w1w2w3w4 d1d2d3");
let h16 = new Hand("m1m1 m2m2 m3m3 m4m4 m5m5 m6m6 m7m7");
let h17 = new Hand("m1m1 m2m2 m3m3 m4m4 m5m5 m6m6 m6m6");
let h18 = new Hand("m1m2m3 m4m4m4 m7m7m7 p1p1p1 p5p5");
let h19 = new Hand("m1m1m1 p1p1p1 s1s1s1 s1s2s3 p5p5");
let h20 = new Hand("m1m1m1 p1p1p1 s1s1 s1s2s3 p5p5p5");
let h21 = new Hand("m1m1m1 m2m3m4 m5m6m7 m8m8 m9m9m9");
let h22 = new Hand("s2s2s2 s8s8s8 s4s4s4 s6s6 d2d2d2");
let h23 = new Hand("m1m1m1 m2m3m4 m5m6m7 m8m8m8 m9m9m9");
let h24 = new Hand("m1m1m1 m2m3m4 m6m6 m5m6m7 m9m9m9");
// lipeikou
count += assert(yakus.lipekou(h5, [], 0) === 1, "m123 m123 p789 p789 w33 is Lipeikou");
count += assert(yakus.lipekou(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Lipeikou");
total += 2;
// ryanpeikou
count += assert(yakus.ryanpeikou(h5, [], 0) === 3, "m123 m123 p789 p789 w33 is Ryanpeikou");
count += assert(yakus.ryanpeikou(h6, [], 0) === 0, "m1123 m123 p678 p789 w33 is not Ryanpeikou");
total += 2;
// pinfu
count += assert(yakus.pinfu(h1, [], 0) === 1, "m123 m456 m789 m123 p55 is Pinfu");
count += assert(yakus.pinfu(h2, [], 0) === 0, "m111 m444 m777 p111 p55 is not Pinfu");
total += 2;
// sanshoku doujun
count += assert(yakus.sanshokuDoujun(h7, [], 0) === 2, "m123 p123 s123 m789 p99 is Shanshokou Doujun");
count += assert(yakus.sanshokuDoujun(h2, [], 0) === 0, "m111 m444 m777 p111 p55 is not Shanshokou Doujun");
total += 2;
// ittsuu
count += assert(yakus.ittsuu(h1, [], 0) === 2, "m123 m456 m789 m123 m55 is Ittsuu");
count += assert(yakus.ittsuu(h2, [], 0) === 0, "m111 m444 m777 p111 p55 is not Ittsu");
total += 2;
// tanyao
count += assert(yakus.tanyao(h4, [], 0) === 1, "m234 p333 p456 s44 s678 is Tanyao");
count += assert(yakus.tanyao(h1, [], 0) === 0, "m123 m456 m789 m123 p55 is not Tanyao");
total += 2;
// yakuhai
count += assert(yakus.yakuhai(h3, [], 2) === 1, "m111 p999 s111 w222 d11 West is Yakuhai");
count += assert(yakus.yakuhai(h8, [], 3) === 2, "m123 m123 p66 w000 w333 North is double Yakuhai");
count += assert(yakus.yakuhai(h3, [], 0) === 0, "m111 p999 s111 w222 d11 Est is not Yakuhai");
total += 3;
// shousangen
count += assert(yakus.shousangen(h9, [], 0) === 2, "m123 m123 d111 d222 d33 is Shousangen");
count += assert(yakus.shousangen(h10, [], 0) === 0, "m123 p66 d111 d222 d333 is not Shousangen");
count += assert(yakus.shousangen(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Shousangen");
total += 3;
// daisangen
count += assert(yakus.daisangen(h10, [], 0) === 13, "m123 p66 d111 d222 d333 is Daisangen");
count += assert(yakus.daisangen(h9, [], 0) === 0, "m123 m123 d111 d222 d33 is not Daisangen");
count += assert(yakus.daisangen(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Daisangen");
total += 3;
// shousuushi
count += assert(yakus.shousuushii(h11, [], 0) === 13, "m123 w111 w222 w333 w44 is Shousuushi");
count += assert(yakus.shousuushii(h12, [], 0) === 0, "m11 w111 w222 w333 w444 is not Shousuushi");
count += assert(yakus.shousuushii(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Shousuushi");
total += 3;
// daisuushi
count += assert(yakus.daisuushi(h12, [], 0) === 13, "m11 w111 w222 w333 w444 is Daisuushi");
count += assert(yakus.daisuushi(h11, [], 0) === 0, "m123 w111 w222 w333 w44 is not Daisuushi");
count += assert(yakus.daisuushi(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Daisuushi");
total += 3;
// chanta
count += assert(yakus.chanta(h13, [], 0) === 2, "d11 w111 w222 w333 w444 is Chanta");
count += assert(yakus.chanta(h12, [], 0) === 2, "m11 w111 w222 w333 w444 is Chanta");
count += assert(yakus.chanta(h10, [], 0) === 0, "m123 p66 d111 d222 d333 is not Chanta");
total += 3;
// junchan
count += assert(yakus.junchan(h7, [], 0) === 3, "m123 p123 s123 m789 p99 is Junchan");
count += assert(yakus.junchan(h10, [], 0) === 0, "m123 p66 d111 d222 d333 is not Junchan");
total += 2;
// honroutou
count += assert(yakus.honroutou(h14, [], 0) === 2, "m11 m999 p111 p999 s111 is Honroutou");
count += assert(yakus.honroutou(h3, [], 0) === 2, "m111 p999 s111 w222 d11 is Honroutou");
count += assert(yakus.honroutou(h10, [], 0) === 0, "m123 p66 d111 d222 d333 is not Honroutou");
total += 3;
// chinroutou
count += assert(yakus.chinroutou(h14, [], 0) === 13, "m11 m999 p111 p999 s111 is Chinroutou");
count += assert(yakus.chinroutou(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Chinroutou");
count += assert(yakus.chinroutou(h10, [], 0) === 0, "m123 p66 d111 d222 d333 is not Chinroutou");
total += 3;
// tsuuisou
count += assert(yakus.tsuuiisou(h13, [], 0) === 13, "d11 w111 w222 w333 w444 is Tsuuisou");
count += assert(yakus.tsuuiisou(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Tsuuisou");
total += 2;
// kokushiMusou
count += assert(yakus.kokushiMusou(h15, [], 0) === 13, "m199 p19 s19 w1234 d123 is Kokushi Musou");
count += assert(yakus.kokushiMusou(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Kokushi Musou");
total += 2;
// chiitoitsu
count += assert(yakus.chiitoitsu(h16, [], 0) === 2, "m11 m22 m33 m44 m55 m66 m77 is Chiitoitsu");
count += assert(yakus.chiitoitsu(h17, [], 0) === 0, "m11 m22 m33 m44 m55 m66 m66 is not Chiitoitsu");
count += assert(yakus.chiitoitsu(h3, [], 0) === 0, "m111 p999 s111 w222 d11 is not Chiitoitsu");
total += 3;
// toitoi
count += assert(yakus.toitoi(h2, [], 0) === 2, "m111 m444 m777 p111 p55 is Toitoi");
count += assert(yakus.toitoi(h7, [], 0) === 0, "m123 p123 s123 m789 p99 is not Toitoi");
total += 2;
// sanankou
count += assert(yakus.sanankou(h18, [], 0) === 2, "m123 m444 m777 p111 p55 is Sanankou");
count += assert(yakus.sanankou(h2, [], 0) === 0, "m111 m444 m777 p111 p55 is not Sanankou");
count += assert(yakus.sanankou(h7, [], 0) === 0, "m123 p123 s123 m789 p99 is not Sanankou");
total += 3;
// sanankou
count += assert(yakus.suuankou(h2, [], 0) === 13, "m111 m444 m777 p111 p55 is Sanankou");
count += assert(yakus.suuankou(h18, [], 0) === 0, "m123 m444 m777 p111 p55 is not Sanankou");
count += assert(yakus.suuankou(h7, [], 0) === 0, "m123 p123 s123 m789 p99 is not Sanankou");
total += 3;
// sanshoku doukou
count += assert(yakus.sanshokuDoukou(h19, [], 0) === 2, "m111 p111 s111 s123 p55 is Shanshoku Doukou");
count += assert(yakus.sanshokuDoukou(h20, [], 0) === 0, "m11 p111 s111 s123 p555 is not Shanshoku Doukou");
count += assert(yakus.sanshokuDoukou(h7, [], 0) === 0, "m123 p123 s123 m789 p99 is not Shanshoku Doukou");
total += 3;
// sankantsu
// count += assert(
// yakus.sankantsu(
// new Hand(),
// [
// new Group([new Tile(1, 1, false), new Tile(1, 1, false), new Tile(1, 1, false), new Tile(1, 1, false)], 0, 0),
// new Group([new Tile(1, 3, false), new Tile(1, 3, false), new Tile(1, 3, false), new Tile(1, 3, false)], 0, 0),
// new Group([new Tile(1, 5, false), new Tile(1, 5, false), new Tile(1, 5, false), new Tile(1, 5, false)], 0, 0),
// ],
// 0
// ) === 2,
// " is Sankantsu");
count += assert(false, "sankantsu not implemented");
total += 1;
// suukantsu
count += assert(false, "suukantsu not implemented");
total += 1;
// honitsu
count += assert(yakus.honitsu(h9, [], 0) === 3, "m123 m123 d111 d222 d33 is Honitsu");
count += assert(yakus.honitsu(h13, [], 0) === 0, "d11 w111 w222 w333 w444 is not Honitsu");
count += assert(yakus.honitsu(h1, [], 0) === 0, "m123 m456 m789 m123 m55 is not Honitsu");
count += assert(yakus.honitsu(h7, [], 0) === 0, "m123 p123 s123 m789 p99 is not Honitsu");
total += 4;
// chinitsu
count += assert(yakus.chinitsu(h1, [], 0) === 6, "m123 m456 m789 m123 m55 is Chinitsu");
count += assert(yakus.chinitsu(h9, [], 0) === 0, "m123 m123 d111 d222 d33 is not Chinitsu");
count += assert(yakus.chinitsu(h13, [], 0) === 0, "d11 w111 w222 w333 w444 is not Chinitsu");
count += assert(yakus.chinitsu(h7, [], 0) === 0, "m123 p123 s123 m789 p99 is not Chinitsu");
total += 4;
// ryuuisou
count += assert(yakus.ryuuisou(h22, [], 0) === 13, "s222 s888 s444 s66 d222 is Ryuuisou");
count += assert(yakus.ryuuisou(h1, [], 0) === 0, "m123 m456 m789 m123 m55 is not Ryuuisou");
total += 2;
// chuuren poutou
count += assert(yakus.chuurenPoutou(h21, [], 0) === 13, "m111 m234 m567 m88 m999 is Chuuren Poutou");
count += assert(yakus.chuurenPoutou(h23, [], 0) === 0, "m111 m234 m567 m888 m999 is not Chuuren Poutou");
count += assert(yakus.chuurenPoutou(h24, [], 0) === 0, "m111 m234 m66 m567 m999 is not Chuuren Poutou");
count += assert(yakus.chuurenPoutou(h1, [], 0) === 0, "m123 m456 m789 m123 m55 is not Chuuren Poutou");
total += 4;
// total
console.log("Succès: " + count.toString() + "/" + total.toString());