commit
0a584bb58a
100 changed files with 146493 additions and 7358 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
node_modules/
|
||||
package-lock.json
|
||||
build/
|
||||
__pycache__
|
||||
17
.prettierrc
Normal file
17
.prettierrc
Normal 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
11
.vscode/extensions.json
vendored
Normal 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
37
.vscode/settings.json
vendored
Normal 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
26
Dockerfile
Normal 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
120
Makefile
|
|
@ -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 ""
|
||||
|
|
|
|||
140
README.md
140
README.md
|
|
@ -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
|
||||
# Lancer le serveur de développement
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- ts-loader ^9.5.2
|
||||
- webpack ^5.98.0
|
||||
- webpack-cli ^6.0.1
|
||||
## 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 |
|
||||
|
||||
## Makefile
|
||||
|
||||
## 🚀 Installation
|
||||
Un Makefile est également disponible :
|
||||
|
||||
- Clonage du dépot: ```git clone https://github.com/Didictateur/Riichi.git && cd Riichi```
|
||||
- Installation des dépendances: ```npm install```
|
||||
- Compilation: ```make```
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Ⓜ️ 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
|
||||
## 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]
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
## Design System
|
||||
|
||||
Le projet utilise un système de variables CSS moderne :
|
||||
|
||||
## 🤝 Source
|
||||
```css
|
||||
:root {
|
||||
--color-primary: #1a7f4b;
|
||||
--color-accent: #ffd700;
|
||||
--bg-main: linear-gradient(...);
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
Les images ont été récupérées depuis le github de [FluffyStuff](https://github.com/FluffyStuff/riichi-mahjong-tiles?tab=readme-ov-file).
|
||||
## 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
|
||||
|
||||
## 📝 Licence
|
||||
Les images des tuiles proviennent du dépôt de [FluffyStuff](https://github.com/FluffyStuff/riichi-mahjong-tiles).
|
||||
|
||||
Tout le projet est dans le [domaine public](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
## 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
124
backend/src/api.py
Normal 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)
|
||||
0
backend/src/game/__init__.py
Normal file
0
backend/src/game/__init__.py
Normal file
0
backend/src/game/discard.py
Normal file
0
backend/src/game/discard.py
Normal file
15
backend/src/game/error.py
Normal file
15
backend/src/game/error.py
Normal 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
20
backend/src/game/game.py
Normal 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
45
backend/src/game/hand.py
Normal 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
116
backend/src/game/tile.py
Normal 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
19
backend/src/game/value.py
Normal 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
23
backend/src/game/wall.py
Normal 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)
|
||||
3
backend/src/requirements.txt
Normal file
3
backend/src/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Flask==3.0.0
|
||||
flask-cors==4.0.0
|
||||
gunicorn==21.2.0
|
||||
142
components/menu.html
Normal file
142
components/menu.html
Normal 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
25
docker-compose.yml
Normal 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
108
frontend/css/hand.css
Normal 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;
|
||||
}
|
||||
66
frontend/css/speech-bubble.css
Normal file
66
frontend/css/speech-bubble.css
Normal 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
26
frontend/css/style.css
Normal 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
238
frontend/js/fr/texts.js
Normal 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, où<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 là, 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 où 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 là, 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û 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
64
frontend/js/hand.js
Normal 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);
|
||||
}
|
||||
}
|
||||
411
frontend/pages/liste_yakus.html
Normal file
411
frontend/pages/liste_yakus.html
Normal 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>
|
||||
108
frontend/pages/ressources.html
Normal file
108
frontend/pages/ressources.html
Normal 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>
|
||||
300
frontend/pages/riichi/chap1.html
Normal file
300
frontend/pages/riichi/chap1.html
Normal 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>
|
||||
79
frontend/pages/riichi/chap10.html
Normal file
79
frontend/pages/riichi/chap10.html
Normal 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>
|
||||
79
frontend/pages/riichi/chap11.html
Normal file
79
frontend/pages/riichi/chap11.html
Normal 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>
|
||||
333
frontend/pages/riichi/chap2.html
Normal file
333
frontend/pages/riichi/chap2.html
Normal 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>
|
||||
144
frontend/pages/riichi/chap3.html
Normal file
144
frontend/pages/riichi/chap3.html
Normal 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>
|
||||
173
frontend/pages/riichi/chap4.html
Normal file
173
frontend/pages/riichi/chap4.html
Normal 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>
|
||||
79
frontend/pages/riichi/chap5.html
Normal file
79
frontend/pages/riichi/chap5.html
Normal 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>
|
||||
79
frontend/pages/riichi/chap6.html
Normal file
79
frontend/pages/riichi/chap6.html
Normal 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>
|
||||
175
frontend/pages/riichi/chap7.html
Normal file
175
frontend/pages/riichi/chap7.html
Normal 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>
|
||||
151
frontend/pages/riichi/chap8.html
Normal file
151
frontend/pages/riichi/chap8.html
Normal 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>
|
||||
79
frontend/pages/riichi/chap9.html
Normal file
79
frontend/pages/riichi/chap9.html
Normal 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
87054
frontend/pdf/Regles.pdf
Normal file
File diff suppressed because it is too large
Load diff
55593
frontend/pdf/Scores.pdf
Normal file
55593
frontend/pdf/Scores.pdf
Normal file
File diff suppressed because it is too large
Load diff
BIN
frontend/pdf/Yakus.pdf
Normal file
BIN
frontend/pdf/Yakus.pdf
Normal file
Binary file not shown.
BIN
img/tilineau/explaining.png
Normal file
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
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
BIN
img/tilineau/speaking.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
288
index.html
288
index.html
|
|
@ -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>
|
||||
|
||||
<div class="container">
|
||||
<div id="canvasContainer"></div>
|
||||
<div id="anotherCanvasContainer"></div>
|
||||
</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);
|
||||
|
||||
<script>
|
||||
let currentScript = null;
|
||||
// Afficher la mascotte
|
||||
const mascolteEl = document.getElementById('mascotte');
|
||||
mascolteEl.innerHTML = '<img src="/img/tilineau/speaking.png" alt="Tilineau">';
|
||||
|
||||
function removePlayButton() {
|
||||
const btn = document.getElementById("playButton");
|
||||
if (btn) btn.remove();
|
||||
}
|
||||
// Afficher le texte de bienvenue
|
||||
document.getElementById('speech-text').innerHTML = texts.homePage.welcome;
|
||||
|
||||
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;
|
||||
|
||||
currentScript = document.createElement("script");
|
||||
currentScript.type = "module";
|
||||
currentScript.src = `build/${scriptName}?t=${timestamp}`;
|
||||
// Lancer la pluie de tuiles
|
||||
startTileRain();
|
||||
});
|
||||
|
||||
document.body.appendChild(currentScript);
|
||||
}
|
||||
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';
|
||||
|
||||
let lastLoadedScript = 'dp0.js';
|
||||
let lastTextMode = true;
|
||||
// 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');
|
||||
|
||||
function loadScript(scriptName, txt = true) {
|
||||
if (window.cleanup) {
|
||||
window.cleanup();
|
||||
}
|
||||
removePlayButton();
|
||||
container.appendChild(tileElem);
|
||||
|
||||
const container = document.getElementById("canvasContainer");
|
||||
container.innerHTML = `<canvas id="myCanvas" width="1000" height="1000"></canvas>`;
|
||||
setTimeout(() => tileElem.remove(), duration * 1000);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
|
|
|
|||
51
nginx.conf
Normal file
51
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
package.json
36
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
317
src/button.ts
317
src/button.ts
|
|
@ -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();
|
||||
}
|
||||
293
src/deck.ts
293
src/deck.ts
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
1491
src/game.ts
1491
src/game.ts
File diff suppressed because it is too large
Load diff
91
src/group.ts
91
src/group.ts
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
382
src/hand.ts
382
src/hand.ts
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
125
src/rain.ts
125
src/rain.ts
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
91
src/state.ts
91
src/state.ts
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {};
|
||||
|
|
@ -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.
|
||||
|
|
@ -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}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -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~
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
|
|
@ -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*
|
||||
|
||||
~Ci‑contre : 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.~
|
||||
|
|
@ -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 !~
|
||||
|
|
@ -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 lui‑mê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 ci‑contre, le joueur peut annoncer sa victoire
|
||||
avec *Ron* sur la défausse du joueur en face en complétant sa
|
||||
combinaison.~
|
||||
|
|
@ -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 anti‑horaire).
|
||||
|
||||
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 ci‑contre, le vent initial est tiré aléatoirement et
|
||||
change en fonction du résultat de chaque manche.~
|
||||
|
|
@ -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 semi‑pure} : 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érez‑vous à la fiche Yaku pour les règles
|
||||
détaillées.)
|
||||
|
|
@ -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 !~
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Cette page n'a pas *encore* été implémentée.
|
||||
|
||||
Merci de votre compréhension et de votre patience.
|
||||
|
||||
|
||||
156
src/tile.ts
156
src/tile.ts
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
import "./test_hand"
|
||||
import "./test_yakus"
|
||||
|
|
@ -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());
|
||||
|
|
@ -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());
|
||||
|
|
@ -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
99
vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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/,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in a new issue