Merge pull request #17 from Didictateur/api

update
This commit is contained in:
Didictateur 2026-02-25 10:59:54 +01:00 committed by GitHub
commit 0a584bb58a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 146493 additions and 7358 deletions

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/
package-lock.json
build/
build/
__pycache__

17
.prettierrc Normal file
View file

@ -0,0 +1,17 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"bracketSameLine": false,
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"embeddedLanguageFormatting": "auto"
}

11
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"formulahendry.auto-rename-tag",
"streetsidesoftware.code-spell-checker",
"streetsidesoftware.code-spell-checker-french",
"ms-vscode.vscode-typescript-next"
]
}

37
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,37 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.exclude": {
"**/node_modules": true,
"**/build": false,
"**/.git": true
},
"search.exclude": {
"**/node_modules": true,
"**/build": true,
"**/*.js.map": true
},
"emmet.includeLanguages": {
"typescript": "html"
}
}

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# Installer les dépendances Python
COPY backend/src/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copier le code backend
COPY backend/src/ /app/
# Copier le frontend
COPY frontend /app/frontend
COPY components /app/components
COPY img /app/img
COPY index.html /app/
COPY *.json /app/
COPY vite.config.ts /app/
# Port Flask
EXPOSE 5000
# Définir PYTHONPATH pour que les imports relatifs fonctionnent
ENV PYTHONPATH=/app:$PYTHONPATH
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "api:app"]

120
Makefile
View file

@ -1,27 +1,107 @@
TESTS = $(wildcard build/test*)
# ==================================
# Riichi Mahjong Tutorial - Makefile
# ==================================
all:
npx webpack --mode production
.PHONY: all dev build preview test lint format clean hard-clean zip help install
# Default target
all: build
# Install dependencies
install:
@echo "Installing dependencies..."
npm install
# Development server with hot reload
dev:
npx webpack --mode development
@echo "Starting development server..."
npm run dev
zip:
npx webpack --mode production
# Production build
build:
@echo "Building for production..."
npm run build
# Preview production build
preview:
@echo "Previewing production build..."
npm run preview
# Run tests
test:
@echo "Running tests..."
npm run test
# Run tests in watch mode
test-watch:
@echo "Running tests in watch mode..."
npm run test:watch
# Lint code
lint:
@echo "Linting code..."
npm run lint
# Fix linting issues
lint-fix:
@echo "Fixing linting issues..."
npm run lint:fix
# Format code
format:
@echo "Formatting code..."
npm run format
# Type check
typecheck:
@echo "Type checking..."
npm run typecheck
# Create distribution zip
zip: build
@echo "Creating distribution archive..."
@rm -rf riichi
mkdir riichi
cp -r build riichi
cp index.html riichi
cp -r img riichi
zip -r riichi.zip riichi
@rm -r riichi
@mkdir riichi
@cp -r build riichi/
@cp index.html riichi/
@cp -r img riichi/
@cp -r src/styles riichi/src/ 2>/dev/null || mkdir -p riichi/src && cp -r src/styles riichi/src/
@zip -r riichi.zip riichi
@rm -rf riichi
@echo "Created riichi.zip"
# Clean build artifacts
clean:
rm -rf build/
rm -f riichi.zip
@echo "Cleaning build artifacts..."
@rm -rf build/
@rm -rf dist/
@rm -f riichi.zip
@rm -rf riichi/
hard-clean:
rm -rf build/
rm -rf node_modules/
rm -f package-lock.json
rm -f richi.zip
# Deep clean (including dependencies)
hard-clean: clean
@echo "Deep cleaning (including dependencies)..."
@rm -rf node_modules/
@rm -f package-lock.json
@rm -rf .cache/
@rm -f *.tsbuildinfo
# Show help
help:
@echo ""
@echo "Riichi Mahjong Tutorial - Available Commands"
@echo "================================================"
@echo ""
@echo " make install - Install npm dependencies"
@echo " make dev - Start development server with hot reload"
@echo " make build - Build for production"
@echo " make preview - Preview production build"
@echo " make test - Run tests"
@echo " make test-watch - Run tests in watch mode"
@echo " make lint - Check code for linting errors"
@echo " make lint-fix - Fix linting errors automatically"
@echo " make format - Format code with Prettier"
@echo " make typecheck - Run TypeScript type checking"
@echo " make zip - Create distribution archive"
@echo " make clean - Remove build artifacts"
@echo " make hard-clean - Remove all generated files and dependencies"
@echo ""

160
README.md
View file

@ -1,83 +1,127 @@
# 🀄 Tutoriel interactif de Mahjong 🀄
Un tutoriel complet et interactif pour apprendre à jouer au mahjong, développé en TypeScript. Ce projet vise à rendre l'apprentissage du mahjong accessible à tous.
# Tutoriel interactif de Mahjong Riichi
<p align="center">
<img src="https://img.shields.io/badge/TypeScript-5.6-blue?logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/Vite-6.0-646CFF?logo=vite" alt="Vite">
<img src="https://img.shields.io/badge/License-CC0-green" alt="License">
</p>
Un tutoriel complet et interactif pour apprendre à jouer au Mahjong Riichi, développé en TypeScript moderne avec une interface élégante.
## 🔗 Démonstration
## Démonstration
Ce tutoriel est accessible directement sur [ce site](https://perso.eleves.ens-rennes.fr/people/adrien.decosse/riichi/).
## Fonctionnalités
- **Guide étape par étape** : Apprentissage progressif découpé en chapitres
- **Interface interactive** : Manipulez les tuiles et expérimentez les dynamiques
- **Exemples de jeu** : Scénarios dirigés pour s'entraîner aux différentes situations
- **Design moderne** : Interface élégante avec animations fluides
- **Performances optimisées** : Rendu canvas optimisé avec double buffering
## ✨ Fonctionnalités
## Stack Technique
- Guide étape par étape: Apprentissage progressif découpé en chapitre
- Interface interactive: Manipulez les tuiles et expérimenter les dynamiques
- Exemples de jeu: Scénarios dirigés pour s'entrainer aux différentes situations
- Non compatible pour le format téléphone ou tablette
| Technologie | Version | Usage |
|------------|---------|-------|
| TypeScript | 5.6+ | Langage principal |
| Vite | 6.0+ | Build tool & dev server |
| Vitest | 2.1+ | Tests unitaires |
| Canvas API | - | Rendu graphique |
## Démarrage Rapide
### Prérequis
## 🛠️ Configuration Typescript
- Node.js 18.0 ou supérieur
- npm ou yarn
- Cible ES2015
- Mode strict activé
- Module de type "esnext"
### Installation
```bash
# Cloner le dépôt
git clone https://github.com/Didictateur/Riichi.git
cd Riichi
# Installer les dépendances
npm install
## 🛠️ Prérequis
- ts-loader ^9.5.2
- webpack ^5.98.0
- webpack-cli ^6.0.1
## 🚀 Installation
- Clonage du dépot: ```git clone https://github.com/Didictateur/Riichi.git && cd Riichi```
- Installation des dépendances: ```npm install```
- Compilation: ```make```
## Ⓜ️ Makefile
Un makefile est à disposition pour exécuter plusieurs commandes:
- `make` compile le projet dans `build/` en mode `production`
- `make dev` compile le projet dans `build/` en mode `development`
- `make zip` compresse le contenu du `build` et les ressource nécessaires dans `riichi.zip`
- `make clean` supprime `riichi.zip` et le contenu de `build/`
- `make hard-clean` en plus de clean, supprime toutes les dépendances installées
## 📁 Structure du projet
```
Riichi/
├── src/
│ ├── img/ # Dossier contenant les images
│ ├── display/ # Composant gérant l'affichage de chaque section
│ ├── text/ # Textes des tutoriels
│ ├── yakus/ # Implémentation des yakus
│ └── [autres fichiers sources]
├── img/ # Images et ressources
├── tests/ # Tests unitaires et d'intégration
├── build/ # Ensemble du projet compilé en JavaScript
└── [autres fichiers de configuration]
# Lancer le serveur de développement
npm run dev
```
## Scripts Disponibles
| Commande | Description |
|----------|-------------|
| `npm run dev` | Lance le serveur de développement avec hot reload |
| `npm run build` | Compile le projet pour la production |
| `npm run preview` | Prévisualise le build de production |
| `npm run typecheck` | Vérifie les types TypeScript |
| `npm run test` | Lance les tests |
| `npm run test:watch` | Lance les tests en mode watch |
## 🤝 Source
## Makefile
Les images ont été récupérées depuis le github de [FluffyStuff](https://github.com/FluffyStuff/riichi-mahjong-tiles?tab=readme-ov-file).
Un Makefile est également disponible :
```bash
make dev # Serveur de développement
make build # Build production
make preview # Prévisualiser le build
make test # Lancer les tests
make clean # Nettoyer les fichiers générés
make hard-clean # Nettoyage complet (incluant node_modules)
make zip # Créer une archive de distribution
make help # Afficher l'aide
```
## Structure du Projet
## 📝 Licence
```
Riichi/
├── src/
│ ├── constants/ # Constantes partagées
│ ├── display/ # Composants d'affichage (dp*.ts)
│ ├── styles/ # Feuilles de style CSS
│ ├── text/ # Textes des tutoriels
│ ├── types/ # Définitions de types TypeScript
│ ├── utils/ # Fonctions utilitaires
│ └── yakus/ # Implémentation des yakus
├── img/ # Images et ressources
├── tests/ # Tests unitaires
├── build/ # Fichiers compilés
├── index.html # Point d'entrée HTML
├── vite.config.ts # Configuration Vite
└── tsconfig.json # Configuration TypeScript
```
Tout le projet est dans le [domaine public](https://creativecommons.org/publicdomain/zero/1.0/).
## Design System
Le projet utilise un système de variables CSS moderne :
```css
:root {
--color-primary: #1a7f4b;
--color-accent: #ffd700;
--bg-main: linear-gradient(...);
/* ... */
}
```
## Contribution
Les contributions sont les bienvenues ! N'hésitez pas à :
1. Fork le projet
2. Créer une branche (`git checkout -b feature/amelioration`)
3. Commit vos changements (`git commit -am 'Ajout de fonctionnalité'`)
4. Push la branche (`git push origin feature/amelioration`)
5. Ouvrir une Pull Request
## Sources
Les images des tuiles proviennent du dépôt de [FluffyStuff](https://github.com/FluffyStuff/riichi-mahjong-tiles).
## Licence
Ce projet est dans le [domaine public](https://creativecommons.org/publicdomain/zero/1.0/) (CC0 1.0).

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

@ -0,0 +1,124 @@
from flask import Flask, jsonify, request
from flask_cors import CORS
from game.tile import *
from game.hand import *
from game.game import *
from game.error import *
app = Flask(__name__)
CORS(app)
tiles = Tile.generateAll()
current_game = None # Stockage de la partie actuelle
# ============= GET ================
@app.route('/api/tiles', methods=['GET'])
def get_all_tiles():
"""Récupère toutes les tuiles"""
tiles_data = [
{
'id': tile.id,
'family': str(tile.family),
'value': str(tile.value),
'svg': tile.get_img()
}
for tile in tiles
]
return jsonify({'tiles': tiles_data})
@app.route('/api/tiles/<int:tile_id>', methods=['GET'])
def get_tile(tile_id):
if tile_id < 0 or tile_id >= len(tiles):
return jsonify({'error': 'Tuile non trouvée'}), 404
tile = tiles[tile_id]
svg = f'<svg viewBox="0 0 500 700" width="100" height="140">{tile.get_img()}</svg>'
return jsonify({
'id': tile.id,
'family': str(tile.family),
'value': str(tile.value),
'svg': svg
})
@app.route('/api/random_hand', methods=['GET'])
def get_random_hand():
"""Récupère une main aléatoire de 13 tuiles"""
hand = Hand.random()
hand_data = [
{
'id': tile.id,
'family': str(tile.family),
'value': str(tile.value),
'svg': tile.get_img()
}
for tile in hand.tiles
]
return jsonify({'hand': hand_data})
@app.route('/api/hand_with_draw', methods=['GET'])
def get_hand_with_draw():
"""Crée une partie et retourne le hand_player avec une tuile piochée"""
global current_game
current_game = Game()
current_game.draw_tile(current_game.hand_player)
hand_data = [
{
'id': tile.id,
'family': str(tile.family),
'value': str(tile.value),
'svg': tile.get_img()
}
for tile in current_game.hand_player.tiles
]
draw_data = {
'id': current_game.hand_player.draw.id,
'family': str(current_game.hand_player.draw.family),
'value': str(current_game.hand_player.draw.value),
'svg': current_game.hand_player.draw.get_img()
} if current_game.hand_player.draw else None
return jsonify({'hand': hand_data, 'draw': draw_data})
@app.route('/api/discard', methods=['POST'])
def discard_tile():
"""Défausse une tuile de la main du joueur et pioche une nouvelle"""
global current_game
if current_game is None:
return jsonify({'error': 'Aucune partie en cours'}), 400
data = request.json
tile_id = data.get('tile_id')
if tile_id is None:
return jsonify({'error': 'tile_id manquant'}), 400
current_game.put_tile(current_game.hand_player, tile_id)
current_game.draw_tile(current_game.hand_player)
# Retourner la main mise à jour
hand_data = [
{
'id': tile.id,
'family': str(tile.family),
'value': str(tile.value),
'svg': tile.get_img()
}
for tile in current_game.hand_player.tiles
]
draw_data = {
'id': current_game.hand_player.draw.id,
'family': str(current_game.hand_player.draw.family),
'value': str(current_game.hand_player.draw.value),
'svg': current_game.hand_player.draw.get_img()
} if current_game.hand_player.draw else None
return jsonify({'hand': hand_data, 'draw': draw_data})
# ============== POST ================
if __name__ == '__main__':
app.run(debug=True, port=5000)

View file

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

20
backend/src/game/game.py Normal file
View file

@ -0,0 +1,20 @@
from __future__ import annotations
from .hand import *
from .wall import *
import random as rd
class Game:
def __init__(self):
self.wall = Wall()
self.hand_player = self.wall.draw_hand()
self.bot_hands = [self.wall.draw_hand() for _ in range(3)]
def draw_tile(self, hand: Hand):
tile = self.wall.draw()
hand.draw_tile(tile)
def put_tile(self, hand: Hand, id: int):
self.wall.tiles.append(hand.discard_tile(id))
self.wall.shuffle()

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

@ -0,0 +1,45 @@
from __future__ import annotations
from .tile import *
import random as rd
class Hand:
def __init__(self, tiles: list[Tile], draw: Tile|None = None):
self.tiles: list[Tile] = tiles
self.draw: Tile|None = draw
def sort(self) -> None:
self.tiles.sort()
def fold(self) -> None:
if self.draw is not None:
self.tiles.append(self.draw)
self.draw = None
self.sort()
def draw_tile(self, tile: Tile) -> None:
self.draw = tile
def discard_tile(self, id: int) -> Tile|None:
if self.draw is not None and self.draw.id == id:
draw = self.draw
self.draw = None
self.fold()
self.sort()
return draw
for i, tile in enumerate(self.tiles):
if tile.id == id:
t = self.tiles.pop(i)
self.fold()
self.sort()
return t
self.sort()
return None
@staticmethod
def random() -> Hand:
tiles = Tile.generateAll()
rd.shuffle(tiles)
hand = Hand(tiles[:13])
hand.sort()
return hand

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

@ -0,0 +1,116 @@
from __future__ import annotations
from game.error import *
from game.value import *
from functools import total_ordering
@total_ordering
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:
shadow = self._load_svg(self.img_shadow, x=10, y=10)
front = self._load_svg(self.img_front, x=0, y=0)
symbol = self._load_svg(self.img_symbol, x=12, y=20, scale=0.9)
ratio = 400 / 300
width = 60
self.img = f"""<svg class="tile tile-{self.id}" data-tile-id="{self.id}" viewBox="0 0 310 410" height="{ratio * width}" width="{width}">{shadow}{front}{symbol}</svg>"""
return self.img
@staticmethod
def _load_svg(filename, x=0, y=0, scale=1):
try:
with open(f'img/Regular/{filename}', 'r', encoding='utf-8') as f:
svg_content = f.read()
return f'<g transform="translate({x}, {y}) scale({scale})">{svg_content}</g>'
except FileNotFoundError:
raise InvalidValueError(f"SVG non trouvé: {filename}")
def __eq__(self, other: Tile):
return self.value == other.value and self.family == other.family
def __lt__(self, other: Tile):
if self.family.value != other.family.value:
return self.family.value < other.family.value
if hasattr(self.value, 'value'):
return self.value.value < other.value.value
return 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

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

@ -0,0 +1,23 @@
from __future__ import annotations
from .tile import *
from .hand import *
import random as rd
class Wall:
def __init__(self):
self.tiles = Tile.generateAll()
self.shuffle()
def shuffle(self):
rd.shuffle(self.tiles)
def draw(self) -> Tile:
if len(self.tiles) == 0:
raise Exception("No more tiles in the wall")
return self.tiles.pop()
def draw_hand(self) -> Hand:
hand_tiles = [self.draw() for _ in range(13)]
hand_tiles.sort()
return Hand(hand_tiles)

View file

@ -0,0 +1,3 @@
Flask==3.0.0
flask-cors==4.0.0
gunicorn==21.2.0

142
components/menu.html Normal file
View file

@ -0,0 +1,142 @@
<nav class="menu">
<ul>
<li><a href="https://github.com/Didictateur/Riichi">
<svg width="1.5em" height="1.5em" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 0.1rem;">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Github
</a></li>
<li><a href="/">Accueil</a></li>
<li class="dropdown">
<a href="#">Riichi</a>
<ul class="submenu">
<li><a href="/frontend/pages/riichi/chap1.html">Chapitre 1 : Les tuiles</a></li>
<li><a href="/frontend/pages/riichi/chap2.html">Chapitre 2 : La main</a></li>
<li><a href="/frontend/pages/riichi/chap3.html">Chapitre 3 : Les groupes</a></li>
<li><a href="/frontend/pages/riichi/chap4.html">Chapitre 4 : Les main bien formées</a></li>
<li><a href="/frontend/pages/riichi/chap5.html">Chapitre 5 : Voler une tuile</a></li>
<li><a href="/frontend/pages/riichi/chap6.html">Chapitre 6 : Annoncer la victoire</a></li>
<li><a href="/frontend/pages/riichi/chap7.html">Chapitre 7 : Le vent du joueur</a></li>
<li><a href="/frontend/pages/riichi/chap8.html">Chapitre 8 : Les Yakus</a></li>
<li><a href="/frontend/pages/riichi/chap9.html">Chapitre 9 : Le riichi</a></li>
<li><a href="/frontend/pages/riichi/chap10.html">Chapitre 10 : Le furiten</a></li>
<li><a href="/frontend/pages/riichi/chap11.html">Chapitre 11 : Une partie complète (complète ?)</a></li>
</ul>
</li>
<li class="dropdown">
<a href="#">Scoring</a>
<ul class="submenu">
<li><a href="">Chapitre 1 : Les Hans</a></li>
<li><a href="">Chapitre 2 : Les doras</a></li>
<li><a href="">Chapitre 3 : Les Fus</a></li>
<li><a href="">Chapitre 4 : Le calcul</a></li>
<li><a href="">Chapitre 5 : Une partie complète</a></li>
</ul>
</li>
<li class="dropdown">
<a href="#">Partie réelle</a>
<ul class="submenu">
<li><a href="">Chapitre 1 : Placement des joueurs</a></li>
<li><a href="">Chapitre 2 : Construire le mur</a></li>
<li><a href="">Chapitre 3 : Utiliser le mur</a></li>
<li><a href="">Chapitre 4 : Le mur mort</a></li>
</ul>
</li>
<li class="dropdown">
<a href="#">Variante à trois</a>
<ul class="submenu">
<li><a href="">Chapitre 1 : Les tuiles</a></li>
<li><a href="">Chapitre 2 : Les vents</a></li>
<li><a href="">Chapitre 3 : Le cas du nord</a></li>
<li><a href="">Chapitre 4 : Les murs</a></li>
<li><a href="">Chapitre 5 : Une partie à trois</a></li>
</ul>
</li>
<li><a href="/frontend/pages/liste_yakus.html">Liste des Yakus</a></li>
<li><a href="/frontend/pages/ressources.html">Ressources</a></li>
</ul>
</nav>
<style>
.menu {
background: linear-gradient(135deg, #1a4d2e 0%, #2d6a4f 100%);
padding: 1rem 0;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
position: sticky;
top: 0;
z-index: 1000;
}
.menu ul {
list-style: none;
display: flex;
gap: 2rem;
justify-content: center;
margin: 0;
padding: 0;
}
.menu a {
color: #e8f5e9;
text-decoration: none;
font-size: 1.1rem;
transition: all 0.3s ease;
display: block;
padding: 0.5rem 1rem;
border-radius: 4px;
}
.menu a:hover {
color: #52b788;
background-color: rgba(82, 183, 136, 0.1);
transform: translateY(-2px);
}
.dropdown {
position: relative;
}
.submenu {
visibility: hidden;
opacity: 0;
position: absolute;
top: 100%;
left: 0;
background: linear-gradient(135deg, #1b5e20 0%, #2d6a4f 100%);
padding: 0;
border-radius: 6px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
list-style: none;
min-width: 220px;
z-index: 9999;
flex-direction: column;
gap: 0.5rem !important;
transition: opacity 0.3s ease;
border: 1px solid rgba(82, 183, 136, 0.3);
}
.dropdown:hover .submenu {
visibility: visible;
opacity: 1;
}
.submenu li {
white-space: nowrap;
margin: 0;
padding: 0;
}
.submenu a {
padding: 0.4rem 1rem;
color: #c8e6c9;
font-size: 0.95rem;
line-height: 1;
margin: 0;
}
.submenu a:hover {
background-color: rgba(82, 183, 136, 0.2);
color: #52b788;
}
</style>

25
docker-compose.yml Normal file
View file

@ -0,0 +1,25 @@
services:
riichi:
build: .
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
volumes:
- ./frontend:/app/frontend
- ./backend/src:/app/backend/src
restart: unless-stopped
nginx:
image: nginx:latest
ports:
- "127.0.0.1:3002:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ${PWD}/frontend:/usr/share/nginx/html/frontend
- ${PWD}/index.html:/usr/share/nginx/html/index.html:ro
- ${PWD}/components:/usr/share/nginx/html/components
- ${PWD}/img:/usr/share/nginx/html/img
depends_on:
- riichi
restart: unless-stopped

108
frontend/css/hand.css Normal file
View file

@ -0,0 +1,108 @@
/* Hand Display Styles */
.hand-display {
margin-bottom: 30px;
grid-column: 1 / -1;
display: grid;
grid-template-columns: 100px 1fr;
gap: 30px;
align-items: start;
}
.hand-label {
color: #ffffff;
margin: 0;
font-size: 1.2rem;
white-space: nowrap;
}
.hand-tiles {
display: grid;
grid-template-columns: repeat(13, minmax(0, 1fr));
gap: 3px;
flex: 0 0 auto;
}
.hand-tile-item {
padding: 0;
text-align: center;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.2s ease;
}
/* Effet hover seulement sur la main interactive */
#interactive-hand .hand-tile-item:hover {
transform: translateY(-10px);
}
/* Hand container - gère l'affichage des tuiles et du draw */
.hand-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
.hand-tile-item.draw-tile {
/* Le draw s'affiche simplement à droite grâce à flex */
flex: 0 0 auto;
}
/* Orientation variations */
.hand-display.vertical {
grid-template-columns: 100px 1fr;
}
.hand-display.horizontal {
grid-template-columns: 1fr;
}
.hand-display.horizontal .hand-label {
grid-column: 1;
margin-bottom: 10px;
}
.hand-display.horizontal .hand-tiles {
grid-column: 1;
}
/* Position variations */
.hand-display.left-label {
grid-template-columns: 100px 1fr;
}
.hand-display.top-label {
grid-template-columns: 1fr;
}
.hand-display.top-label .hand-label {
grid-column: 1;
margin-bottom: 10px;
}
.hand-display.top-label .hand-tiles {
grid-column: 1;
}
/* Tilt variations */
.hand-display.tilt-left {
transform: rotate(90deg);
transform-origin: center;
}
.hand-display.tilt-right {
transform: rotate(-90deg);
transform-origin: center;
}
.hand-display.tilt-upside-down {
transform: rotate(180deg);
transform-origin: center;
}
.hand-display.tilt-none {
transform: rotate(0deg);
transform-origin: center;
}

View file

@ -0,0 +1,66 @@
.speech-bubble {
background: white;
border: 2px solid #333;
border-radius: 10px;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
gap: 10px;
}
.speech-bubble::after {
content: '';
position: absolute;
bottom: -12px;
right: 20px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 0 solid transparent;
border-top: 12px solid #333;
}
.speech-bubble::before {
content: '';
position: absolute;
bottom: -8px;
right: 22px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 0 solid transparent;
border-top: 10px solid white;
}
.speech-text {
flex: 1;
white-space: nowrap;
font-size: 1.5rem;
}
.dialog-buttons {
display: flex;
gap: 5px;
margin-left: 10px;
}
.dialog-btn {
padding: 4px 8px;
border: 1px solid #333;
background: #f0f0f0;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.dialog-btn:hover:not(:disabled) {
background: #333;
color: white;
}
.dialog-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

26
frontend/css/style.css Normal file
View file

@ -0,0 +1,26 @@
/* Style global */
html {
overflow-y: auto;
}
body {
margin: 0;
padding: 0;
background: radial-gradient(circle at center, #1a5c1a 0%, #0d3d0d 100%);
background-attachment: fixed;
font-family: Arial, sans-serif;
overscroll-behavior: none;
}
/* Brillance subtile */
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(ellipse at center, transparent 0%, rgba(0,0,0,0.15) 100%);
pointer-events: none;
z-index: -1;
}

238
frontend/js/fr/texts.js Normal file
View file

@ -0,0 +1,238 @@
const texts = {
homePage: {
welcome: `
Bienvenue dans le monde du Riichi Mahjong !<br><br>
Installe toi confortablement, et prépare toi à découvrir ce vaste univers.<br>
Tu as fais un très bon choix en venant ici, et je suis sûr que tu vas adorer ce jeu !<br><br>
Le Riichi Mahjong est un jeu de société d'origine chinoise, qui a été adapté au Japon et qui est<br>
maintenant joué dans le monde entier. C'est un jeu de stratégie, de chance et de psychologie, <br>
les joueurs s'affrontent pour former des combinaisons de tuiles et remporter la partie.<br><br>
Que tu sois complètement nouveau, ou légèrement familier, ce site est fait pour toi. Nous allons<br>
te guider à travers les règles du jeu pour que tu puisses profiter d'une partie.<br><br>
Alors prépare tes tuiles, rassemble tes amis, et plonge dans l'aventure du Riichi Mahjong !<br><br><br>
Ah, j'ai oublié de me présenter ! Je suis Tilineau, ton guide à partir de maintenant. Ne t'inquiète<br>
pas, tu es entre de bonnes mains.<br><br>
Pour obtenir mon aide et ouvrir et fermer ce dialogue, il suffit de cliquer sur moi, je ne vais<br>
pas te mordre !
`
},
riichi: {
chap1: `
Voici l'ensemble des tuiles au Riichi. Chacune des tuiles présentent ici<br>
existe en exactement 4 exemplaires, formant un jeu de 136 tuiles.<br>
<br>
Il y a 3 types de tuiles:<br>
- Les familles : Caractères, Ronds et Bambous<br>
- Les vents : Est, Sud, Ouest et Nord<br>
- Les dragons : Rouge, Vert et Blanc<br>
<br>
Les familles sont numérotées de 1 à 9, exactement comme dans un jeu<br>
de cartes.<br>
<br>
Les vents et dragons quant à eux n'ont pas de numéro. On les appelle<br>
les honneurs, et ne sont caractérisés que par leur nom.
`,
chap2:`
Les joueurs ne se contentent pas de regarder les tuiles. Ils<br>
constituent une main composée de 13 tuiles.<br>
<br>
Au début de leur tour, ils commencent par récupérer une<br>
tuile, soit du mur (la pioche), soit de la défausse d'un<br>
adversaire (nous verrons les détails plus tard).<br>
<br>
Après avoir récupéré une tuile, le joueur doit se défausser<br>
d'une tuile de sa main, pour revenir à 13 tuiles. C'est à ce<br>
moment que les autres joueurs peuvent réagir à la tuile<br>
défaussée, en la récupérant pour compléter leur propre main.
`,
chap3: `
Avec ces tuiles, les joueurs peuvent s'amuser à former des<br>
combinaisons.<br>
<br>
En soit, c'en est même la mécanique principale. On en distingue<br>
deux types:<br>
- Les brelans : 3 tuiles identiques<br>
- Les suites : 3 tuiles consécutives de la même famille<br>
<br>
Attention, on ne peux pas non plus faire n'importe quoi avec !<br>
- Ces combinaisons nécessitent toujours 3 tuiles de la même famille<br>
- Les vents et les dragons ne peuvent pas former de suite,<br>
seulement des brelans<br>
<br>
Point vocabulaire ! Au riichi, nous utilisons les termes:<br>
- Chii pour une suite<br>
- Pon pour un brelan<br>
`,
chap4:`
Pour gagner, il faut avoir nécessairement un main bien formée.<br>
cela a évidemment un rapport avec les groupes vus précédément.<br>
<br>
Pour être exacte, une main est bien formée si elle possède:<br>
- 4 groupes de 3 (pon ou chii)<br>
- 1 paire<br>
Je précise qu'une paire doit être formée par deux tuiles identique.<br>
<br>
Si tu as bien suivie, tu remarquera que cela correspond à un total<br>
de 14 tuiles dans sa main. Ainsi, il n'est possible d'avoir une main<br>
bien formée que lorsque l'on pioche ou vole une tuile.<br>
<br>
Plus tard, nous verrons qu'il existe deux exceptions à cette<br>
forme bien précise.
`,
chap5:`
Maintenant, passons aux choses sérieuses: le vole !<br>
Enfin... Le vole de tuile en tout cas. Lorsque quelqu'un<br>
défausse une tuile, tu as la possibilité de la récupérer<br>
si elle t'arrange.<br>
<br>
Mais attention, même voler doit se faire dans les règles<br>
de l'art:<br>
- seule la dernière tuile défaussée peut être volée<br>
- tu peux voler n'importe qui pour former un pon<br>
- tu ne peux voler que la personne précédente pour un chii<br>
- le pon a la priorité sur le chii<br>
- on ne peut pas voler pour former une paire<br>
<br>
Tu as tout retenu ? Ne t'inquiètes pas, ça va vite devenir<br>
clair si tu t'y entraines.
`,
chap6:`
Avant d'attaquer le point clef du Riichi, nous allons<br>
nous pencher un peu plus sur la déclaration de la victoire.<br>
<br>
Pour commencer, lorsqu'une personne est à une seule tuile<br>
de la victoire (4 groupes de prêts, et presque pour le dernier),<br>
nous disons qu'elle est en Tenpai. C'est l'équivalent du "Uno"<br>
version MahJong.<br>
<br>
À ce moment , si une personne défausse une tuile qui complète<br>
sa main - que ça complète un pon, un chii ou même une pair - elle<br>
gagne immédiatement.<br>
<br>
Pour être précis, il y a deux situations:<br>
- la tuile gagnante est piochée, c'est un "Tsumo"<br>
- la tuile gagnante est volée, c'est un "Ron"<br>
<br>
Ces terme sont importants, car ils doivent être pronnoncés pour<br>
signaler la victoire !<br>
<br>
<br>
Au passage, s'il n'y a plus de pioche, alors il y a égalité.
`,
chap7:`
Si vous avez déjà joué au tarot, vous savez très bien que le<br>
placement des joueurs est important. Il l'est encore plus<br>
au Riichi.<br>
<br>
Pour se distinguer, chaque joueur se voit attribuer un vent<br>
nommé par les directions cardinales (Est, Sud, Ouest, Nord).<br>
<br>
Sachant que:<br>
- l'Est est celui qui commence à jouer (c'est le Dealer)<br>
- le vent tourne à la fin de chaque manche<br>
<br>
Il y a cependant une seule exception: si c'est l'Est qui gagne,<br>
alors il y a une répétition, et les vents ne tournent pas. La<br>
partie s'arrête quand tout le monde a été le joueur Est.<br>
<br>
<br>
Point culture: Tu pourras également remarquer que l'Est et<br>
l'Ouest sont inversés. C'est tout à fait normal ! En effet,<br>
les chinois étaient à cette époque très familiers avec l'univers<br>
marin. Or, en mer, nous utilisons des cartes célestes les <br>
axes sont inversés.
`,
chap8:`
Jettons-nous maintenant dans le feu de l'action: les Yakus.<br>
<br>
Pour déclarer victoire, avoir une main formée n'est pas suffisant. Il faut<br>
également avoir ce que l'on appelle un "Yaku". C'est une organisation particulière<br>
de sa main qui la rend rare et unique. Plus cette organisation est rare, et plus<br>
la main va valoir beaucoup de point en cas de victoire.<br>
<br>
La plupart du temps, il suffit de connaitre quelques yakus pour pouvoir jouer et<br>
s'amuser. Par exemple:<br>
- brelan de dragon<br>
- brelan d'Est, ou de son propre vent<br>
- main (semie) pure: toutes les tuiles (plus des vents/dragons) de la même famille<br>
- tout ordinaire: pas de dragon, ni de vent, ni de 1, ni de 9<br>
<br>
Il y a également deux Yakus qui sortent du lot:<br>
- 7 paires: explicite et assez courant. Attention ! Toutes les paires sont distinctes<br>
- 13 orphelins: réalisable une fois dans sa vie, mais très beau yaku<br>
<br>
Une liste exhaustive des yakus les plus courants au moins courants sont présentés dans<br>
l'onglet dédié.
`,
chap9:`
Le Riichi est le yaku le plus important du jeu ! Ce n'est pas pour rien<br>
que ça s'appelle ainsi.<br>
<br>
Le principe est très simple. Lorsqu'une personne est en Tenpai (à une tuile<br>
de la victoire), et que sa main est FERMÉE, alors il peut déclarer le riichi.<br>
À ce moment , elle ne peut plus changer sa main: soit elle gagne par Tsumo<br>
ou Ron, soit elle défausse une tuile qui fait gagner quelqu'un d'autre.<br>
<br>
À partir de maintenant, il va donc falloir bien réfléchir aux appels<br>
(pon et chii), puisque cela ouvre la main et fixe des groupes, et va donc<br>
restreindre les yakus qui seront réalisables.
`,
chap10:`
À ce stade de l'histoire, vous conaissez toutes les règles principales pour jouer<br>
au Riichi. Mais on va s'attarder sur le cas du Furiten. Pour faire court, c'est<br>
l'inderdiction pour une personne de faire Ron si elle aurait déjà avoir gagné.<br>
<br>
Il y a deux cas:<br>
- Une des tuiles de ta défausse pourrait compléter ta main<br>
- Tu refuses de Ron quelqu'un<br>
<br>
Dans le premier cas, tu es foutu. Tu as l'interdiction de faire un Ron, et tu ne<br>
peux gagner qu'avec un Tsumo. Dans le second cas, c'est un furiten temporaire,<br>
et il faut attendre ton tour pour pouvoir Ron de nouveau.<br>
<br>
Précisions:<br>
- Même si une tuile ne te donne pas de yaku, du momentqu'elle te permet de<br>
rendre ta main valide, il y a la question du furiten<br>
- Une tuile t'empêche de faire ron sur N'IMPORTE QU'ELLE tuile<br>
<br>
L'objectif derrière cette règle est d'empêcher de viser quelqu'un spécifiquement.<br>
<br>
Cela permet également de savoir quelles tuiles peuvent être défausser pour ne pas<br>
faire gagner quelqu'un d'autre.
`,
chap11:`
Ça y est, tu as fait le plus gros travail ! Félicitation<br>
d'être arrivé jusqu'ici !<br>
<br>
Après t'être entrainé à jouer des parties, je teconseil<br>
très vivement de lire la section Scoring, a minima les<br>
deux premiers chapitres
`,
},
liste_yakus:{
explanation:`
Voici la liste exhaustive des yakus avec leur fréquence d'apparition<br>
selon le site Tenhou pour janvier 2026.<br>
<br>
Je précise qu'une main peut avoir plusieurs yakus en même temps,<br>
donc la somme des fréquences dépasse 100%.<br>
<br>
De surcroît, ce classement est très dépendante des joueurs. En effet<br>
les 7 paires est un yaku accessible, mais peu de joueurs confirmés le<br>
choisissent, car sa forme particulière permet très difficilement de<br>
manipuler sa main pour en avoir des plus intéressantes.
`,
},
};

64
frontend/js/hand.js Normal file
View file

@ -0,0 +1,64 @@
/**
* Hand Display Object
* Affiche une main de Riichi avec options de position et orientation
*/
class Hand {
constructor(options = {}) {
this.label = options.label || 'Main';
this.position = options.position || 'left-label'; // 'left-label' or 'top-label'
this.tilt = options.tilt || 'none'; // 'none', 'left' (90deg), 'right' (-90deg), 'upside-down' (180deg)
this.containerId = options.containerId || 'tiles-display';
this.apiUrl = options.apiUrl || '/api/random_hand';
}
/**
* Récupère une main aléatoire et l'affiche
*/
async render() {
try {
const response = await fetch(this.apiUrl);
const data = await response.json();
this.displayHand(data.hand);
} catch (error) {
console.error('Erreur lors de la récupération de la main:', error);
}
}
/**
* Affiche la main dans le DOM
* @param {Array} tiles - Tableau des tuiles à afficher
*/
displayHand(tiles) {
const container = document.getElementById(this.containerId);
// Créer la section de la main
const handDiv = document.createElement('div');
const classNames = `hand-display ${this.position}`;
if (this.tilt !== 'none') {
handDiv.className = `${classNames} tilt-${this.tilt}`;
} else {
handDiv.className = classNames;
}
// Créer le label
const label = document.createElement('div');
label.className = 'hand-label';
label.textContent = this.label;
handDiv.appendChild(label);
// Créer la grille de tuiles
const handTiles = document.createElement('div');
handTiles.className = 'hand-tiles';
tiles.forEach(tile => {
const tileDiv = document.createElement('div');
tileDiv.className = 'hand-tile-item';
tileDiv.innerHTML = tile.svg;
handTiles.appendChild(tileDiv);
});
handDiv.appendChild(handTiles);
container.appendChild(handDiv);
}
}

View file

@ -0,0 +1,411 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Liste des Yakus - Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 380px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
h1 {
text-align: center;
color: white;
margin: 40px 0 50px 0;
font-size: 2.5rem;
}
.yakus-container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
display: grid;
gap: 20px;
}
.yaku-card {
background: rgba(45, 85, 60, 0.8);
border: 2px solid rgba(82, 183, 136, 0.6);
border-radius: 10px;
padding: 20px;
display: grid;
grid-template-columns: 1fr 100px;
gap: 20px;
align-items: center;
transition: all 0.3s ease;
}
.yaku-card:hover {
background: rgba(45, 85, 60, 1);
border-color: rgba(82, 183, 136, 1);
box-shadow: 0 0 20px rgba(82, 183, 136, 0.3);
transform: translateX(5px);
}
.yaku-info h3 {
color: #52b788;
margin: 0 0 10px 0;
font-size: 1.3rem;
}
.yaku-info p {
color: #c8e6c9;
margin: 0;
line-height: 1.5;
font-size: 0.95rem;
}
.yaku-percentage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #52b788, #40916c);
border-radius: 8px;
padding: 15px;
min-height: 80px;
}
.percentage-number {
font-size: 2rem;
font-weight: bold;
color: white;
}
.percentage-label {
font-size: 0.8rem;
color: #e8f5e9;
margin-top: 5px;
}
.yaku-bar {
width: 100%;
height: 4px;
background: rgba(82, 183, 136, 0.3);
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
}
.yaku-bar-fill {
height: 100%;
background: linear-gradient(90deg, #52b788, #74c69d);
border-radius: 2px;
}
</style>
</head>
<body>
<div id="menu"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<h1>Yakus les plus courants</h1>
<div class="yakus-container" id="yakus-list"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.liste_yakus.explanation;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/explaining.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
// Données des yakus
const yakus = [
{
name: 'Riichi',
description: 'Déclarer Tenpai avec une main fermée',
percentage: 41.9
},
{
name: 'Brelan de dragon',
description: 'Brelan de dragon vert, blanc ou rouge',
percentage: 23.8
},
{
name: 'Tanyao',
description: 'Main sans honneurs (dragon et vent) ni terminaux (1 et 9)',
percentage: 23.3
},
{
name: 'Pinfu',
description: 'Que des suites avec une double attente',
percentage: 19.9
},
{
name: 'Tout caché tiré',
description: 'Faire Tsumo avec une main fermée',
percentage: 17.8
},
{
name: 'Ippatsu',
description: 'Faire Ron ou Tsumo dans le tour ininterrompu (sans appel) suivant la déclaration du Riichi',
percentage: 9.2
},
{
name: 'Vent de la manche',
description: 'Souvent Est, parfois Sud',
percentage: 8.3
},
{
name: 'Vent du joueur',
description: 'Selon le vent correspondant au joueur',
percentage: 8.17
},
{
name: 'Semie pure',
description: "Que des tuiles de la même famille, en plus d'honneurs (vent et dragons)",
percentage: 6.3
},
{
name: 'Double suite pure',
description: 'Avoir dans une main fermée deux suites identiques',
percentage: 4.2
},
{
name: 'Triple suite mixte',
description: 'Avoir la même suite dans chacune des trois familles',
percentage: 3.5
},
{
name: 'Tout brelan',
description: "N'avoir que des brelans dans sa main, en pluis de la paire",
percentage: 3.0
},
{
name: '7 paires',
description: "N'avoir que des paires distinctes",
percentage: 2.3
},
{
name: 'Grande suite pure',
description: 'Avoir les groupes 123, 456 et 789 dans la même famille',
percentage: 1.6
},
{
name: 'Terminaux et honneurs partout',
description: "Présence d'au moins un honneur (vent et dragon) ou terminal (1 et 9) dans chacun des groupes",
percentage: 1.0
},
{
name: 'Pure',
description: 'Toutes les tuiles sont de la même famille, excluant les honneurs (vents et dragons)',
percentage: 0.87
},
{
name: 'Trois brelans cachés',
description: "Avoir trois brelans dans sa main non issue d'un appel",
percentage: 0.68
},
{
name: 'Under the river',
description: '',
percentage: 0.57
},
{
name: 'Terminal partout',
description: "Présence d'au moins un honneur (vents et dragons) dans chacun des groupes",
percentage: 0.32
},
{
name: 'Under the sea',
description: '',
percentage: 0.31
},
{
name: 'Finir sur unn carré',
description: "Gagner sur la tuile de remplacement de d'un carré",
percentage: 0.28
},
{
name: 'Double riichi',
description: 'Déclarer Riichi dès son premier tour',
percentage: 0.17
},
{
name: 'Trois petits dragons',
description: 'Avoir deux brelans de dragons, et une paire du troisième dragon',
percentage: 0.12
},
{
name: 'Tout terminal et honneur',
description: "Avoir une main composée exclusivement d'honneurs (vents et dragons) et de terminaux (1 et 9)",
percentage: 0.058
},
{
name: "Vole d'un Khan",
description: "Flemme d'expliquer",
percentage: 0.057
},
{
name: 'Deux doubles suites pures',
description: 'Avoir deux fois deux suites identiques dans une main fermée',
percentage: 0.042
},
{
name: 'Quatre brelans cachés',
description: 'Avoir quatre brelans dans une main fermée',
percentage: 0.040
},
{
name: 'Triple brelan mixte',
description: 'Avoir une brelan de la même valeur dans chacune des trois familles',
percentage: 0.038
},
{
name: 'Trois grands dragons',
description: 'Avec un brelan de chaque dragon',
percentage: 0.032
},
{
name: '13 orphelins',
description: 'Avoir dans sa main un exemplaire de chaque terminaux (1 et 9) et honneurs (vents et dragons). Une des tuiles doit en plus former une paire',
percentage: 0.022
},
{
name: 'Quatre petits vents',
description: 'Avoir trois brelans de vent et une paire du dernier vent',
percentage: 0.0095
},
{
name: 'Tout honneur',
description: "N'avoir que des honneurs (vents et dragons) dans sa main",
percentage: 0.0054
},
{
name: 'Trois Khan',
description: "Former trois khan dans sa main",
percentage: 0.0035
},
{
name: 'Tout terminal',
description: "N'avoir que des terminaux (1 et 9) dans sa main",
percentage: 0.00059
},
{
name: 'Quatre grands vents',
description: 'Avoir un un brelan de chaque vent',
percentage: 0.00059
},
{
name: 'Bénédiction du ciel',
description: 'Le joueur Est gagne sur sa pioche initiale',
percentage: 0.00044
},
{
name: 'Bénédiction de la terre',
description: 'Gagner sur une pioche durant le tout premier tour ininterrompu (sans appel)',
percentage: 0.00044
},
{
name: 'Neufs portes',
description: 'Avoir exactement 1112345678999 plus une tuile, le tout dans une seule famille',
percentage: 0.00044
},
{
name: 'Main verte',
description: 'Avoir une main ne comptenant que 1, 2, 3, 4, 6, 8 de bambou et le dragon vert',
percentage: 0.00030
},
{
name: 'Quatres khans',
description: 'Former quatre khans dans sa main',
percentage: 0.00015
},
];
// Afficher les yakus
const yakusList = document.getElementById('yakus-list');
yakus.forEach(yaku => {
const card = document.createElement('div');
card.className = 'yaku-card';
const info = document.createElement('div');
info.className = 'yaku-info';
const title = document.createElement('h3');
title.textContent = yaku.name;
const desc = document.createElement('p');
desc.textContent = yaku.description;
const bar = document.createElement('div');
bar.className = 'yaku-bar';
const barFill = document.createElement('div');
barFill.className = 'yaku-bar-fill';
barFill.style.width = yaku.percentage + '%';
bar.appendChild(barFill);
info.appendChild(title);
info.appendChild(desc);
info.appendChild(bar);
const percentage = document.createElement('div');
percentage.className = 'yaku-percentage';
const number = document.createElement('div');
number.className = 'percentage-number';
number.textContent = yaku.percentage + '%';
const label = document.createElement('div');
label.className = 'percentage-label';
label.textContent = 'Fréquence';
percentage.appendChild(number);
percentage.appendChild(label);
card.appendChild(info);
card.appendChild(percentage);
yakusList.appendChild(card);
});
</script>
</body>
</html>

View file

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Ressources - Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<style>
#menu {
position: relative;
z-index: 10;
}
h2 {
text-align: center;
color: white;
margin: 40px 0 50px 0;
font-size: 2.5rem;
}
.resources-list {
max-width: 700px;
margin: 40px auto;
padding: 0;
list-style: none;
}
.resource-item {
background: rgba(255,255,255,0.95);
border-radius: 12px;
margin-bottom: 18px;
padding: 18px 22px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border-left: 5px solid #1a5c1a;
transition: box-shadow 0.2s;
}
.resource-item:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.resource-title {
font-size: 1.2em;
font-weight: bold;
color: #1a5c1a;
margin-bottom: 6px;
}
.resource-desc {
color: #333;
font-size: 1em;
margin-bottom: 8px;
}
.resource-link {
color: #4ecdc4;
text-decoration: underline;
font-weight: bold;
}
</style>
</head>
<body>
<div id="menu"></div>
<h2>Ressources complémentaires</h2>
<ul class="resources-list">
<li class="resource-item">
<div class="resource-title">Guide PDF du Riichi Mahjong</div>
<div class="resource-desc">Un document complet pour réviser les règles et les bases du jeu.</div>
<a class="resource-link" href="/frontend/pdf/Regles.pdf" target="_blank">Voir le PDF</a>
</li>
<li class="resource-item">
<div class="resource-title">Liste des Yakus</div>
<div class="resource-desc">Une fiche récapitulative de tous les yakus triés et organisé.</div>
<a class="resource-link" href="/frontend/pdf/Yakus.pdf" target="_blank">Voir le PDF</a>
</li>
<li class="resource-item">
<div class="resource-title">Tableau des scores</div>
<div class="resource-desc">Tableau complet des score et rappel des Hans et Fus.</div>
<a class="resource-link" href="/frontend/pdf/Scores.pdf" target="_blank">Voir le PDF</a>
</li>
<li class="resource-item">
<div class="resource-title">Livre de stratégie au Riichi</div>
<div class="resource-desc">Livre de référence pour apprendre les bases de la stratégie au mahjong.</div>
<a class="resource-link" href="https://dainachiba.github.io/RiichiBooks/" target="_blank">Riichi Book I</a>
</li>
<li class="resource-item">
<div class="resource-title">S'entrainer à compter les points</div>
<div class="resource-desc">Site d'entrainement de compte des points sur des mains aléatoire.</div>
<a class="resource-link" href="https://scoringtrainer.konbamwa.net/" target="_blank">Visiter le site</a>
</li>
<li class="resource-item">
<div class="resource-title">S'entrainer à l'efficacité</div>
<div class="resource-desc">Il peut être parfois utile de savoir comment avoir une main valide le plus rapidement possible.</div>
<a class="resource-link" href="https://euophrys.itch.io/mahjong-efficiency-trainer" target="_blank">Visiter le site</a>
</li>
<li class="resource-item">
<div class="resource-title">Source des images</div>
<div class="resource-desc">Les images ont été récupérées depuis le github de FluffyStuff.</div>
<a class="resource-link" href="https://github.com/FluffyStuff/riichi-mahjong-tiles?tab=readme-ov-file" target="_blank">Visiter le Git</a>
</li>
<li class="resource-item">
<div class="resource-title">MahJong Handle</div>
<div class="resource-desc">Petit jeu où il faut deviner la main</div>
<a class="resource-link" href="https://mahjong-handle.update.sh/" target="_blank">Jouer</a>
</li>
</ul>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
</script>
</body>
</html>

View file

@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 300px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
#tiles-display {
padding: 20px;
max-width: 900px;
margin: 20px auto;
display: grid;
grid-template-columns: 100px 1fr;
gap: 30px;
align-items: start;
}
.tiles-family {
margin-bottom: 0;
grid-column: 1 / -1;
display: grid;
grid-template-columns: 100px 1fr;
gap: 30px;
align-items: start;
}
.tiles-family h3 {
color: #ffffff;
margin: 0;
font-size: 1.2rem;
white-space: nowrap;
}
.tiles-grid {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 3px;
}
.tile-item {
padding: 0;
text-align: center;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
}
.tiles-numbers {
grid-column: 2;
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 3px;
margin-bottom: 20px;
}
.tiles-numbers div {
text-align: center;
font-weight: bold;
color: #ffffff;
padding: 5px 0;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.wind-names {
grid-column: 2;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 3px;
margin-bottom: 20px;
}
.wind-names div {
text-align: center;
font-weight: bold;
color: #ffffff;
padding: 5px 0;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.dragon-names {
grid-column: 2;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 3px;
margin-bottom: 20px;
}
.dragon-names div {
text-align: center;
font-weight: bold;
color: #ffffff;
padding: 5px 0;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div id="menu"></div>
<div id="tile-container"></div>
<div id="tiles-display">
<div class="tiles-numbers">
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
<div>6</div>
<div>7</div>
<div>8</div>
<div>9</div>
</div>
</div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
// Afficher le texte de bienvenue
document.getElementById('speech-text').innerHTML = texts.riichi.chap1;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
if (speechBubble.classList.contains('visible')) {
mascolteEl.innerHTML = '<img class="explaining" src="/img/tilineau/explaining.png" alt="Tilineau">';
} else {
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
}
});
let allTiles = [];
// Récupérer les tuiles
fetch('/api/tiles')
.then(r => r.json())
.then(data => {
allTiles = data.tiles;
const tilesDisplay = document.getElementById('tiles-display');
// Man
let section = document.createElement('div');
section.className = 'tiles-family';
const manTitle = document.createElement('h3');
manTitle.textContent = 'Caractères';
const manGrid = document.createElement('div');
manGrid.className = 'tiles-grid';
section.appendChild(manTitle);
section.appendChild(manGrid);
for (let i = 0; i < 9; i++) {
const tileDiv = document.createElement('div');
tileDiv.className = 'tile-item';
tileDiv.innerHTML = allTiles[i].svg;
manGrid.appendChild(tileDiv);
}
tilesDisplay.appendChild(section);
// Pin
section = document.createElement('div');
section.className = 'tiles-family';
const pinTitle = document.createElement('h3');
pinTitle.textContent = 'Ronds';
const pinGrid = document.createElement('div');
pinGrid.className = 'tiles-grid';
section.appendChild(pinTitle);
section.appendChild(pinGrid);
for (let i = 9; i < 18; i++) {
const tileDiv = document.createElement('div');
tileDiv.className = 'tile-item';
tileDiv.innerHTML = allTiles[i].svg;
pinGrid.appendChild(tileDiv);
}
tilesDisplay.appendChild(section);
// Sou
section = document.createElement('div');
section.className = 'tiles-family';
const souTitle = document.createElement('h3');
souTitle.textContent = 'Bambous';
const souGrid = document.createElement('div');
souGrid.className = 'tiles-grid';
section.appendChild(souTitle);
section.appendChild(souGrid);
for (let i = 18; i < 27; i++) {
const tileDiv = document.createElement('div');
tileDiv.className = 'tile-item';
tileDiv.innerHTML = allTiles[i].svg;
souGrid.appendChild(tileDiv);
}
tilesDisplay.appendChild(section);
// Ligne des noms de vents
const windNamesDiv = document.createElement('div');
windNamesDiv.className = 'wind-names';
windNamesDiv.innerHTML = '<div>Est</div><div>Sud</div><div>Ouest</div><div>Nord</div>';
tilesDisplay.appendChild(windNamesDiv);
// Vents
section = document.createElement('div');
section.className = 'tiles-family';
const windTitle = document.createElement('h3');
windTitle.textContent = 'Vents';
const windGrid = document.createElement('div');
windGrid.className = 'tiles-grid';
windGrid.style.gridTemplateColumns = 'repeat(4, minmax(0, 1fr))';
section.appendChild(windTitle);
section.appendChild(windGrid);
const windNames = ['Est', 'Sud', 'Ouest', 'Nord'];
for (let i = 27; i < 31; i++) {
const tileDiv = document.createElement('div');
tileDiv.className = 'tile-item';
tileDiv.innerHTML = allTiles[i].svg;
windGrid.appendChild(tileDiv);
}
tilesDisplay.appendChild(section);
// Ligne des noms de dragons
const dragonNamesDiv = document.createElement('div');
dragonNamesDiv.className = 'dragon-names';
dragonNamesDiv.innerHTML = '<div>Blanc</div><div>Vert</div><div>Rouge</div>';
tilesDisplay.appendChild(dragonNamesDiv);
// Dragons
section = document.createElement('div');
section.className = 'tiles-family';
const dragonTitle = document.createElement('h3');
dragonTitle.textContent = 'Dragons';
const dragonGrid = document.createElement('div');
dragonGrid.className = 'tiles-grid';
dragonGrid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))';
section.appendChild(dragonTitle);
section.appendChild(dragonGrid);
for (let i = 31; i < 34; i++) {
const tileDiv = document.createElement('div');
tileDiv.className = 'tile-item';
tileDiv.innerHTML = allTiles[i].svg;
dragonGrid.appendChild(tileDiv);
}
tilesDisplay.appendChild(section);
});
</script>
</body>
</html>

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 100px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap10;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/explaining.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
</script>
</body>
</html>

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 500px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap11;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/explaining.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
</script>
</body>
</html>

View file

@ -0,0 +1,333 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 350px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
#tiles-display {
padding: 20px;
max-width: 900px;
margin: 20px auto;
display: grid;
grid-template-columns: 100px 1fr;
gap: 30px;
align-items: start;
}
.section-title {
grid-column: 1 / -1;
color: #ffffff;
font-size: 1.5rem;
margin: 20px 0 10px 0;
font-weight: bold;
}
.hands-container {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 30px;
justify-content: flex-start;
}
.hands-container .hand-display {
grid-column: auto;
margin-bottom: 0;
width: 100%;
}
.tiles-family {
margin-bottom: 0;
grid-column: 1 / -1;
display: grid;
grid-template-columns: 100px 1fr;
gap: 30px;
align-items: start;
}
.tiles-family h3 {
color: #ffffff;
margin: 0;
font-size: 1.2rem;
white-space: nowrap;
}
.tiles-grid {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 3px;
}
.tile-item {
padding: 0;
text-align: center;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
}
.tiles-numbers {
grid-column: 2;
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 3px;
margin-bottom: 20px;
}
.tiles-numbers div {
text-align: center;
font-weight: bold;
color: #ffffff;
padding: 5px 0;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div id="menu"></div>
<div id="tile-container"></div>
<div id="tiles-display"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script src="/frontend/js/hand.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
// Afficher le texte de bienvenue
document.getElementById('speech-text').innerHTML = texts.riichi.chap2;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
if (speechBubble.classList.contains('visible')) {
mascolteEl.innerHTML = '<img class="explaining" src="/img/tilineau/speaking.png" alt="Tilineau">';
} else {
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
}
});
// Créer le titre de section
const tilesDisplay = document.getElementById('tiles-display');
const title = document.createElement('div');
title.className = 'section-title';
title.textContent = 'Exemples de mains';
tilesDisplay.appendChild(title);
// Créer le conteneur des mains
const handsContainer = document.createElement('div');
handsContainer.className = 'hands-container';
tilesDisplay.appendChild(handsContainer);
// Créer et afficher trois mains aléatoires
for (let i = 0; i < 3; i++) {
const hand = new Hand({
label: `Main ${i + 1}`,
position: 'top-label',
tilt: 'none',
containerId: 'tiles-display'
});
// Modifier temporairement le containerId pour ajouter à handsContainer
hand.containerId = null;
fetch(hand.apiUrl)
.then(r => r.json())
.then(data => {
const handDiv = document.createElement('div');
handDiv.className = `hand-display ${hand.position} tilt-${hand.tilt}`;
const label = document.createElement('div');
label.className = 'hand-label';
label.textContent = `Main ${i + 1}`;
handDiv.appendChild(label);
const container = document.createElement('div');
container.className = 'hand-container';
const handTiles = document.createElement('div');
handTiles.className = 'hand-tiles';
data.hand.forEach(tile => {
const tileDiv = document.createElement('div');
tileDiv.className = 'hand-tile-item';
tileDiv.innerHTML = tile.svg;
handTiles.appendChild(tileDiv);
});
container.appendChild(handTiles);
handDiv.appendChild(container);
handsContainer.appendChild(handDiv);
})
.catch(error => console.error('Erreur:', error));
}
// Créer le titre pour la main interactive
const interactiveTitle = document.createElement('div');
interactiveTitle.className = 'section-title';
interactiveTitle.textContent = 'Main interactive';
tilesDisplay.appendChild(interactiveTitle);
// Créer et afficher la main interactive avec une tuile piochée
fetch('/api/hand_with_draw')
.then(r => r.json())
.then(data => {
const handDiv = document.createElement('div');
handDiv.className = 'hand-display top-label tilt-none';
handDiv.id = 'interactive-hand';
const label = document.createElement('div');
label.className = 'hand-label';
label.textContent = 'Votre main';
handDiv.appendChild(label);
// Conteneur qui gère l'affichage avec flex (gère auto le draw si présent)
const container = document.createElement('div');
container.className = 'hand-container';
// Créer le grid des 13 tuiles
const handTiles = document.createElement('div');
handTiles.className = 'hand-tiles';
handTiles.id = 'interactive-tiles';
data.hand.forEach(tile => {
const tileDiv = document.createElement('div');
tileDiv.className = 'hand-tile-item';
tileDiv.innerHTML = tile.svg;
tileDiv.dataset.tileId = tile.id;
tileDiv.style.cursor = 'pointer';
tileDiv.addEventListener('click', () => discardTile(tile.id, tileDiv));
handTiles.appendChild(tileDiv);
});
container.appendChild(handTiles);
const drawTileDiv = document.createElement('div');
drawTileDiv.className = 'hand-tile-item draw-tile';
drawTileDiv.innerHTML = data.draw.svg;
drawTileDiv.dataset.tileId = data.draw.id;
drawTileDiv.style.cursor = 'pointer';
drawTileDiv.addEventListener('click', () => discardTile(data.draw.id, drawTileDiv));
container.appendChild(drawTileDiv);
handDiv.appendChild(container);
tilesDisplay.appendChild(handDiv);
})
.catch(error => console.error('Erreur:', error));
// Fonction pour défausser une tuile
async function discardTile(tileId, tileElement) {
try {
const response = await fetch('/api/discard', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tile_id: tileId })
});
if (!response.ok) {
console.error('Erreur lors de la défausse');
return;
}
const data = await response.json();
// Ajouter une animation de suppression
tileElement.style.opacity = '0';
tileElement.style.transition = 'opacity 0.3s ease';
setTimeout(() => {
// Mettre à jour la main interactive avec la nouvelle main
updateInteractiveHand(data.hand, data.draw);
}, 300);
} catch (error) {
console.error('Erreur:', error);
}
}
// Fonction pour mettre à jour la main interactive
function updateInteractiveHand(handData, drawData) {
const handTiles = document.getElementById('interactive-tiles');
handTiles.innerHTML = '';
handData.forEach(tile => {
const tileDiv = document.createElement('div');
tileDiv.className = 'hand-tile-item';
tileDiv.innerHTML = tile.svg;
tileDiv.dataset.tileId = tile.id;
tileDiv.style.cursor = 'pointer';
tileDiv.addEventListener('click', () => discardTile(tile.id, tileDiv));
handTiles.appendChild(tileDiv);
});
// Mettre à jour le draw
const container = handTiles.parentElement;
const oldDrawTile = container.querySelector('.draw-tile');
if (oldDrawTile) {
oldDrawTile.remove();
}
if (drawData) {
const drawTileDiv = document.createElement('div');
drawTileDiv.className = 'hand-tile-item draw-tile';
drawTileDiv.innerHTML = drawData.svg;
drawTileDiv.dataset.tileId = drawData.id;
drawTileDiv.style.cursor = 'pointer';
drawTileDiv.addEventListener('click', () => discardTile(drawData.id, drawTileDiv));
container.appendChild(drawTileDiv);
}
}
</script>
</body>
</html>

View file

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 220px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
[id^="tiles-display"] {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding: 20px;
}
#tiles-container {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 60px;
padding: 20px;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<h2>Combinaisons valides</h2>
<div id="tiles-container">
<div id="tiles-display"></div>
<div id="tiles-display-2"></div>
</div>
<div id="tiles-container">
<div id="tiles-display-3"></div>
<div id="tiles-display-4"></div>
</div>
<h2>Combinaisons invalides</h2>
<div id="tiles-container">
<div id="tiles-display-5"></div>
<div id="tiles-display-6"></div>
</div>
<div id="tiles-container">
<div id="tiles-display-7"></div>
<div id="tiles-display-8"></div>
</div>
<div id="tiles-display-9"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script src="/frontend/js/hand.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap3;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/speaking.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
// Fonction utilitaire pour afficher les tuiles
function displayTiles(displayId, tileIndices) {
fetch('/api/tiles')
.then(r => r.json())
.then(data => {
const display = document.getElementById(displayId);
tileIndices.forEach(idx => {
const tile = data.tiles[idx];
if (tile) {
const tileDiv = document.createElement('div');
tileDiv.innerHTML = tile.svg;
display.appendChild(tileDiv);
}
});
})
.catch(error => console.error('Erreur:', error));
}
// Afficher les combinaisons
displayTiles('tiles-display', [10, 10, 10]);
displayTiles('tiles-display-2', [11, 12, 13]);
displayTiles('tiles-display-3', [30, 30, 30]);
displayTiles('tiles-display-4', [32, 32, 32]);
displayTiles('tiles-display-5', [21, 22]);
displayTiles('tiles-display-6', [11, 12, 13, 14]);
displayTiles('tiles-display-7', [27, 28, 29]);
displayTiles('tiles-display-8', [31, 32, 33]);
displayTiles('tiles-display-9', [3, 13, 23, 6, 16, 26]);
</script>
</body>
</html>

View file

@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 250px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
[id^="tiles-display"] {
display: flex;
justify-content: center;
align-items: center;
gap: 5px;
padding: 20px;
}
#tiles-container {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 10px;
padding: 20px;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<h2>Mains bien formées</h2>
<div id="tiles-container">
<div id="tiles-display"></div>
<div id="tiles-display-2"></div>
<div id="tiles-display-3"></div>
<div id="tiles-display-4"></div>
<div id="tiles-display-5"></div>
</div>
<div id="tiles-container">
<div id="tiles-display-6"></div>
<div id="tiles-display-7"></div>
<div id="tiles-display-8"></div>
<div id="tiles-display-9"></div>
<div id="tiles-display-10"></div>
</div>
<h2>Mains mal formées</h2>
<div id="tiles-container">
<div id="tiles-display-11"></div>
<div id="tiles-display-12"></div>
<div id="tiles-display-13"></div>
<div id="tiles-display-14"></div>
<div id="tiles-display-15"></div>
</div>
<div id="tiles-container">
<div id="tiles-display-16"></div>
<div id="tiles-display-17"></div>
<div id="tiles-display-18"></div>
<div id="tiles-display-19"></div>
<div id="tiles-display-20"></div>
</div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script src="/frontend/js/hand.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap4;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/explaining.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
// Fonction utilitaire pour afficher les tuiles
function displayTiles(displayId, tileIndices) {
fetch('/api/tiles')
.then(r => r.json())
.then(data => {
const display = document.getElementById(displayId);
tileIndices.forEach(idx => {
const tile = data.tiles[idx];
if (tile) {
const tileDiv = document.createElement('div');
tileDiv.innerHTML = tile.svg;
display.appendChild(tileDiv);
}
});
})
.catch(error => console.error('Erreur:', error));
}
// Afficher les combinaisons
// main 1
displayTiles('tiles-display', [10, 10, 10]);
displayTiles('tiles-display-2', [21, 22, 23]);
displayTiles('tiles-display-3', [11, 12, 13]);
displayTiles('tiles-display-4', [31, 31, 31]);
displayTiles('tiles-display-5', [32, 32]);
// main 2
displayTiles('tiles-display-6', [1, 2, 3]);
displayTiles('tiles-display-7', [1, 2, 3]);
displayTiles('tiles-display-8', [2, 3, 4]);
displayTiles('tiles-display-9', [4, 4, 4]);
displayTiles('tiles-display-10', [5, 5]);
// main 3
displayTiles('tiles-display-11', [9, 10, 11])
displayTiles('tiles-display-12', [12, 13, 14])
displayTiles('tiles-display-13', [15, 16, 17])
displayTiles('tiles-display-14', [20, 21, 22])
displayTiles('tiles-display-15', [23, 24, 25])
// main 4
displayTiles('tiles-display-16', [0, 1, 2])
displayTiles('tiles-display-17', [10, 11, 12])
displayTiles('tiles-display-18', [19, 19, 19])
displayTiles('tiles-display-19', [27, 27])
displayTiles('tiles-display-20', [29, 29])
</script>
</body>
</html>

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 250px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap5;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/explaining.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
</script>
</body>
</html>

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 150px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap6;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/speaking.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
</script>
</body>
</html>

View file

@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 100px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
h2 {
position: absolute;
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
pointer-events: none;
}
#winds-box {
position: absolute;
pointer-events: none;
top: 200px;
left: 50px;
width: 200px;
height: 200px;
background: rgba(30, 30, 50, 0.9);
border: 3px solid rgba(150, 180, 220, 0.8);
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.wind {
position: absolute;
width: 55px;
height: 55px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 16px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.2s;
}
.wind:hover {
transform: scale(1.1);
}
.wind.north { top: 5px; left: 50%; transform: translateX(-50%); background: linear-gradient(135deg, #ff6b6b, #ff8787); }
.wind.east { left: 5px; top: 50%; transform: translateY(-50%); background: linear-gradient(135deg, #4ecdc4, #44a08d); }
.wind.south { bottom: 5px; left: 50%; transform: translateX(-50%); background: linear-gradient(135deg, #f9ca24, #f0932b); }
.wind.west { right: 5px; top: 50%; transform: translateY(-50%); background: linear-gradient(135deg, #a29bfe, #6c5ce7); }
.arrow {
position: absolute;
width: 10px;
height: 250px;
background: rgb(231, 142, 224);
left: 50%;
top: 20px;
transform: translateX(-50%);
}
.arrow::after {
content: '';
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
border-top: 25px solid rgb(231, 142, 224);
}
</style>
</head>
<body>
<div id="menu"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<!-- schema -->
<div id="winds-box" style="position: absolute; top:200px; left: 850px">
<div class="wind east">E</div>
<div class="wind south">S</div>
<div class="wind west">O</div>
<div class="wind north">N</div>
</div>
<div id="winds-box" style="position: absolute; top:700px; left: 400px">
<div class="wind east">E</div>
<div class="wind south">S</div>
<div class="wind west">O</div>
<div class="wind north">N</div>
</div>
<div id="winds-box" style="position: absolute; top:700px; left: 1300px">
<div class="wind east">N</div>
<div class="wind south">E</div>
<div class="wind west">S</div>
<div class="wind north">O</div>
</div>
<!-- flèches -->
<div class="arrow" style="top: 420px; left: 700px; transform: rotate(45deg)"></div>
<div class="arrow" style="top: 420px; left: 1200px; transform: rotate(-45deg)"></div>
<!-- textes -->
<h2 style="position: absolute; top: 100px; left: 860px">Position initiale</h2>
<h2 style="position: absolute; top: 900px; left: 370px">L'Est gagne la manche</h2>
<h2 style="position: absolute; top: 900px; left: 1230px">L'Est ne gagne pas la manche</h2>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap7;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/speaking.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
</script>
</body>
</html>

View file

@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 110px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
[id^="tiles-display"] {
display: flex;
justify-content: center;
align-items: center;
gap: 5px;
padding: 20px;
}
#tiles-container {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 10px;
padding: 20px;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<h2>Brelan de valeur (dragons, Est ou vent du joueur)</h2>
<div id="tiles-display"></div>
<h2>Tout ordinaire</h2>
<div id="tiles-display-2"></div>
<h2>Triple suite</h2>
<div id="tiles-display-3"></div>
<h2>Suite pure</h2>
<div id="tiles-display-4"></div>
<h2>Pure</h2>
<div id="tiles-display-5"></div>
<h2>Semie pure</h2>
<div id="tiles-display-6"></div>
<h2>7 paires</h2>
<div id="tiles-display-7"></div>
<h2>Tout brelan</h2>
<div id="tiles-display-8"></div>
<h2>Pinfu (uniquement suites + double attente)</h2>
<div id="tiles-display-9"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap8;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/speaking.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
// Fonction utilitaire pour afficher les tuiles
function displayTiles(displayId, tileIndices) {
fetch('/api/tiles')
.then(r => r.json())
.then(data => {
const display = document.getElementById(displayId);
tileIndices.forEach(idx => {
const tile = data.tiles[idx];
if (tile) {
const tileDiv = document.createElement('div');
tileDiv.innerHTML = tile.svg;
display.appendChild(tileDiv);
}
});
})
.catch(error => console.error('Erreur:', error));
}
displayTiles('tiles-display', [4, 5, 6, 8, 8, 8, 14, 14, 24, 25, 26, 32, 32, 32]);
displayTiles('tiles-display-2', [4, 5, 6, 7, 7, 7, 14, 14, 20, 21, 22, 24, 24, 24]);
displayTiles('tiles-display-3', [4, 5, 6, 13, 14, 15, 22, 23, 24, 17, 17, 20, 20, 20]);
displayTiles('tiles-display-4', [9, 10, 11, 12, 13, 14, 15, 16, 17, 23, 23, 23, 27, 27]);
displayTiles('tiles-display-5', [18, 18, 18, 20, 21, 22, 21, 22, 23, 25, 25, 26, 26, 26]);
displayTiles('tiles-display-6', [18, 18, 18, 20, 21, 22, 21, 22, 23, 25, 25, 27, 27, 27]);
displayTiles('tiles-display-7', [1, 1, 6, 6, 18, 18, 19, 19, 24, 24, 28, 28, 30, 30]);
displayTiles('tiles-display-8', [2, 2, 2, 7, 7, 7, 20, 20, 25, 25, 25, 29, 29, 29]);
displayTiles('tiles-display-9', [1, 2, 3, 9, 10, 11, 15, 16, 17, 20, 21, 28, 28])
</script>
</body>
</html>

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<link rel="stylesheet" href="/frontend/css/hand.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 350px;
right: 170px;
z-index: 1;
display: none;
}
.speech-bubble.visible {
display: flex;
}
h2 {
text-align: center;
margin-top: 40px;
margin-bottom: 20px;
color: white;
}
</style>
</head>
<body>
<div id="menu"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
// Afficher la mascotte et le texte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/idle.png" alt="Tilineau">';
document.getElementById('speech-text').innerHTML = texts.riichi.chap9;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('visible');
mascolteEl.innerHTML = speechBubble.classList.contains('visible')
? '<img class="explaining" src="/img/tilineau/speaking.png" alt="Tilineau">'
: '<img src="/img/tilineau/idle.png" alt="Tilineau">';
});
</script>
</body>
</html>

87054
frontend/pdf/Regles.pdf Normal file

File diff suppressed because it is too large Load diff

55593
frontend/pdf/Scores.pdf Normal file

File diff suppressed because it is too large Load diff

BIN
frontend/pdf/Yakus.pdf Normal file

Binary file not shown.

BIN
img/tilineau/explaining.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
img/tilineau/idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
img/tilineau/speaking.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View file

@ -1,192 +1,134 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Mahjong</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #007730;
}
.menu {
display: flex;
justify-content: center;
background-color: #4CAF50;
padding: 0px 0;
font-size: 26px;
}
.menu-item {
position: relative;
list-style: none;
z-index: 9999;
}
.menu-item > a {
color: white;
text-decoration: none;
padding: 20px 20px;
display: block;
}
.menu-item > a:hover {
background-color: #45a049;
}
.dropdown {
display: none;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-40%);
background-color: white;
box-shadow: 0px 5px 8px rgba(0, 0, 0, 0.1);
min-width: 200px;
white-space: nowrap;
font-size: 20px;
}
.dropdown a {
color: black;
text-decoration: none;
padding: 10px 10px;
display: block;
white-space: nowrap;
width: 100%;
box-sizing: border-box;
}
.dropdown a:hover {
background-color: #ddd;
}
.menu-item:hover .dropdown {
display: block;
}
.container {
display: flex;
justify-content: space-betwenne;
}
</style>
<meta charset="UTF-8">
<title>Tuto Riichi</title>
<link rel="stylesheet" href="/frontend/css/style.css">
<link rel="stylesheet" href="/frontend/css/speech-bubble.css">
<style>
#menu {
position: relative;
z-index: 10;
}
#tile-rain {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.falling-tile {
position: absolute;
top: -100px;
animation: fall linear forwards;
}
@keyframes fall {
to {
transform: translateY(100vh) rotate(var(--rotation));
opacity: 0;
}
}
#mascotte {
position: absolute;
top: 670px;
right: 100px;
z-index: 999;
transform: scaleX(-1);
cursor: pointer;
}
#mascotte img {
width: 250px;
height: auto;
display: block;
}
.speech-bubble {
position: absolute;
top: 100px;
right: 170px;
z-index: 1;
display: flex;
width: 1025px;
height: 560px;
}
.speech-bubble.hidden {
display: none;
}
</style>
</head>
<body>
<nav class="menu">
<ul class="menu-item">
<a href="https://github.com/Didictateur/Riichi">Github</a>
</ul>
<ul class="menu-item">
<a href="#" onclick="loadScript('dp0.js')">Introduction</a>
</ul>
<ul class="menu-item">
<a href="#">Riichi Mahjong</a>
<div class="dropdown">
<a href="#" onclick="loadScript('dp1.js')">Chap 1: Présentation des tuiles</a>
<a href="#" onclick="loadScript('dp2.js')">Chap 2: La main</a>
<a href="#" onclick="loadScript('dp3.js')">Chap 3: Les groupes</a>
<a href="#" onclick="loadScript('dp4.js')">Chap 4: Avoir une main valide en jeu</a>
<a href="#" onclick="loadScript('dp5.js')">Chap 5: Annoncer la victoire</a>
<a href="#" onclick="loadScript('dp6.js')">Chap 6: Le vent du joueur</a>
<a href="#" onclick="loadScript('dp7.js')">Chap 7: Les yakus</a>
<a href="#" onclick="loadScript('dp8.js')">Chap 8: Le riichi</a>
<a href="#" onclick="loadScript('dp9.js')">Chap 9: Le furiten</a>
<a href="#" onclick="loadScript('dp10.js')">Chap 10: Première partie complète</a>
<a href="#" onclick="loadScript('dp11.js')">Chap 11: Score: les Hans</a>
<a href="#" onclick="loadScript('dp12.js')">Chap 12: Score: les doras</a>
<a href="#" onclick="loadScript('dp13.js')">Chap 13: Score: les Fus</a>
<a href="#" onclick="loadScript('dp14.js')">Chap 14: Score: le calcul des points</a>
<a href="#" onclick="loadScript('dp15.js')">Chap 15: Partie avec les scores</a>
</div>
</ul>
<ul class="menu-item">
<a href="#">Partie réelle</a>
<div class="dropdown">
<a href="#" onclick="loadScript('dp16.js')">Chap 1: Placement des joueurs</a>
<a href="#" onclick="loadScript('dp17.js')">Chap 2: Construction des murs</a>
<a href="#" onclick="loadScript('dp18.js')">Chap 3: Désigner la pioche</a>
<a href="#" onclick="loadScript('dp19.js')">Chap 4: Le mur mort</a>
</ul>
<ul class="menu-item">
<a href="#" onclick="loadScript('dp20.js')">Riichi à trois</a>
</ul>
<ul class="menu-item">
<a href="#">Yaku trainer</a>
<div class="dropdown">
<a href="#" onclick="loadScript('dp21.js')">Yaku 1</a>
</div>
</ul>
<ul class="menu-item">
<a href="#">MCR</a>
<div class="dropdown">
<a href="#" onclick="loadScript('dp22.js')">Chap 1:</a>
<a href="#" onclick="loadScript('dp23.js')">Chap 2:</a>
</div>
</ul>
</nav>
<div id="menu"></div>
<div id="tile-container"></div>
<div id="tile-rain"></div>
<div class="speech-bubble">
<div class="speech-text" id="speech-text"></div>
</div>
<div id="mascotte"></div>
<script src="/frontend/js/fr/texts.js"></script>
<script>
// Charger le menu
fetch('/components/menu.html')
.then(r => r.text())
.then(html => document.getElementById('menu').innerHTML = html);
<div class="container">
<div id="canvasContainer"></div>
<div id="anotherCanvasContainer"></div>
</div>
// Afficher la mascotte
const mascolteEl = document.getElementById('mascotte');
mascolteEl.innerHTML = '<img src="/img/tilineau/speaking.png" alt="Tilineau">';
<script>
let currentScript = null;
// Afficher le texte de bienvenue
document.getElementById('speech-text').innerHTML = texts.homePage.welcome;
function removePlayButton() {
const btn = document.getElementById("playButton");
if (btn) btn.remove();
}
function loadText(scriptName, nb) {
if (window.cleanup) {
window.cleanup();
}
removePlayButton();
window.txtNumber = nb;
// Toggle bulle de dialogue au clic sur la mascotte
const speechBubble = document.querySelector('.speech-bubble');
mascolteEl.addEventListener('click', () => {
speechBubble.classList.toggle('hidden');
mascolteEl.innerHTML = speechBubble.classList.contains('hidden')
? '<img class="explaining" src="/img/tilineau/idle.png" alt="Tilineau">'
: '<img src="/img/tilineau/speaking.png" alt="Tilineau">';
});
const container = document.getElementById("anotherCanvasContainer");
container.innerHTML = `<canvas id="myTextCanvas" width="1000" height="1000"></canvas>`;
let allTiles = [];
const timestamp = new Date().getTime();
// Récupérer les tuiles
fetch('/api/tiles')
.then(r => r.json())
.then(data => {
allTiles = data.tiles;
// Lancer la pluie de tuiles
startTileRain();
});
currentScript = document.createElement("script");
currentScript.type = "module";
currentScript.src = `build/${scriptName}?t=${timestamp}`;
function startTileRain() {
const container = document.getElementById('tile-rain');
setInterval(() => {
const randomTile = allTiles[Math.floor(Math.random() * allTiles.length)];
const tileElem = document.createElement('div');
tileElem.className = 'falling-tile';
tileElem.innerHTML = randomTile.svg;
tileElem.style.left = Math.random() * window.innerWidth + 'px';
const duration = 8 + Math.random() * 2;
tileElem.style.animationDuration = duration + 's';
// Rotation aléatoire à droite ou à gauche
const rotation = Math.random() * 720 - 360;
const direction = Math.random() > 0.5 ? 1 : -1;
tileElem.style.setProperty('--rotation', (rotation * direction) + 'deg');
container.appendChild(tileElem);
document.body.appendChild(currentScript);
}
let lastLoadedScript = 'dp0.js';
let lastTextMode = true;
setTimeout(() => tileElem.remove(), duration * 1000);
}, 300);
}
function loadScript(scriptName, txt = true) {
if (window.cleanup) {
window.cleanup();
}
removePlayButton();
const container = document.getElementById("canvasContainer");
container.innerHTML = `<canvas id="myCanvas" width="1000" height="1000"></canvas>`;
const timestamp = new Date().getTime();
currentScript = document.createElement("script");
currentScript.type = "module";
currentScript.src = `build/${scriptName}?t=${timestamp}`;
document.body.appendChild(currentScript);
lastLoadedScript = scriptName;
lastTextMode = txt;
if (txt) {
const number = scriptName.substring(2, scriptName.length - 3);
loadText("txt.js", number);
}
}
document.addEventListener('keydown', function(event) {
if (event.keyCode === 13 || event.key === 'Enter') {
loadScript(lastLoadedScript, lastTextMode);
}
});
loadScript('dp0.js');
</script>
</script>
</body>
</html>
</html>

51
nginx.conf Normal file
View file

@ -0,0 +1,51 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
server {
listen 80;
server_name riichi.cosmoris.fr;
root /usr/share/nginx/html;
index index.html;
# Cache pour les assets
location ~* \.(css|js|svg|png|jpg|jpeg|gif|ico)$ {
expires 1d;
add_header Cache-Control "public, immutable";
}
# Proxy vers le backend Flask
location /api/ {
proxy_pass http://riichi:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Servir les fichiers statiques la fin, après les autres locations)
location / {
try_files $uri $uri/ /index.html;
}
}
}

View file

@ -1,8 +1,38 @@
{
"name": "riichi-mahjong-tutorial",
"version": "2.0.0",
"description": "Tutoriel interactif de Mahjong Riichi en TypeScript",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^22.15.3",
"ts-loader": "^9.5.2",
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
"terser": "^5.44.1",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vitest": "^2.1.0"
},
"keywords": [
"mahjong",
"riichi",
"tutorial",
"typescript",
"canvas",
"interactive"
],
"author": "",
"license": "CC0-1.0",
"repository": {
"type": "git",
"url": "https://github.com/Didictateur/Riichi.git"
},
"engines": {
"node": ">=18.0.0"
}
}

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,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(50, 50, 1000, 1000);
// tuiles
let x = 180;
let y = 80;
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:", 55, y + delta + eps);
ctx.fillText("Rond:", 55, y + 3 * delta + yos * size + eps);
ctx.fillText("Bambou:", 55, y + 5 * delta + 2 * yos * size + eps);
ctx.fillText("Vent:", 55, y + 7 * delta + 3 * yos * size + eps);
ctx.fillText("Dragon:", 55, 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(50, 50, 1000, 1000);
// Draw title
ctx.fillStyle = "#DFDFFF";
ctx.font = "50px serif";
ctx.fillText("Exemples de main:", 65, 100);
// 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,127 +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;
// 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,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,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());

View file

@ -1,9 +1,29 @@
{
"compilerOptions": {
"target": "es2015",
"module": "esnext",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"sourceMap": true
}
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@display/*": ["src/display/*"],
"@text/*": ["src/text/*"],
"@yakus/*": ["src/yakus/*"]
}
},
"include": ["src/**/*.ts", "tests/**/*.ts", "vite.config.ts"],
"exclude": ["node_modules", "dist", "build"]
}

99
vite.config.ts Normal file
View file

@ -0,0 +1,99 @@
import { readdirSync } from 'fs';
import { resolve } from 'path';
import { defineConfig } from 'vite';
// Auto-discover entry points
function getEntryPoints(): Record<string, string> {
const entries: Record<string, string> = {};
// Display modules (dp*.ts)
const displayDir = resolve(__dirname, 'src/display');
try {
readdirSync(displayDir).forEach(file => {
if (file.startsWith('dp') && file.endsWith('.ts')) {
const name = file.replace('.ts', '');
entries[name] = resolve(displayDir, file);
}
});
} catch {
console.warn('Display directory not found');
}
// Text modules (txt.ts only for the main loader)
const textDir = resolve(__dirname, 'src/text');
try {
readdirSync(textDir).forEach(file => {
if (file === 'txt.ts') {
entries['txt'] = resolve(textDir, file);
}
});
} catch {
console.warn('Text directory not found');
}
// Test modules
const testDir = resolve(__dirname, 'tests');
try {
readdirSync(testDir).forEach(file => {
if (file.startsWith('test') && file.endsWith('.ts')) {
const name = file.replace('.ts', '');
entries[name] = resolve(testDir, file);
}
});
} catch {
console.warn('Tests directory not found');
}
return entries;
}
export default defineConfig({
// Base path for deployment
base: './',
// Development server configuration
server: {
port: 3000,
open: true,
cors: true,
},
// Preview server (for production build testing)
preview: {
port: 4173,
open: true,
},
// Path aliases
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@display': resolve(__dirname, 'src/display'),
'@text': resolve(__dirname, 'src/text'),
'@yakus': resolve(__dirname, 'src/yakus'),
},
},
// Build configuration
build: {
outDir: 'build',
sourcemap: true,
minify: 'terser',
target: 'es2022',
// Multiple entry points
rollupOptions: {
input: getEntryPoints(),
output: {
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
},
// CSS configuration
css: {
devSourcemap: true,
},
});

View file

@ -1,55 +0,0 @@
const path = require('path');
const fs = require('fs');
// Fonction pour détecter automatiquement les fichiers dp*.ts
function getEntryPoints() {
const displayDir = path.resolve(__dirname, 'src', 'display');
const textDir = path.resolve(__dirname, 'src', 'text');
const testDir = path.resolve(__dirname, '.', 'tests');
const files = fs.readdirSync(displayDir); // Lire les fichiers du répertoire
const texts = fs.readdirSync(textDir);
const tests = fs.readdirSync(testDir);
const entryPoints = {};
files.forEach((file) => {
if (file.startsWith('dp') && file.endsWith('.ts')) {
const name = path.basename(file, '.ts'); // Nom du fichier sans extension
entryPoints[name] = path.join(displayDir, file); // Chemin complet du fichier
}
});
texts.forEach((file) => {
if (file.startsWith('txt') && file.endsWith('.ts')) {
const name = path.basename(file, '.ts');
entryPoints[name] = path.join(textDir, file);
}
});
tests.forEach((file) => {
if (file.startsWith('test') && file.endsWith('.ts')) {
const name = path.basename(file, '.ts');
entryPoints[name] = path.join(testDir, file);
}
});
return entryPoints;
}
module.exports = {
entry: getEntryPoints(), // Générer automatiquement les points d'entrée
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'build'),
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
};