renew of the page

This commit is contained in:
Didictateur 2026-01-02 18:39:20 +01:00
parent e9d16e8eaa
commit 0d2cb7919c
17 changed files with 1935 additions and 321 deletions

17
.prettierrc Normal file
View file

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

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

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

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

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

120
Makefile
View file

@ -1,27 +1,107 @@
TESTS = $(wildcard build/test*) # ==================================
# Riichi Mahjong Tutorial - Makefile
# ==================================
all: .PHONY: all dev build preview test lint format clean hard-clean zip help install
npx webpack --mode production
# Default target
all: build
# Install dependencies
install:
@echo "Installing dependencies..."
npm install
# Development server with hot reload
dev: dev:
npx webpack --mode development @echo "Starting development server..."
npm run dev
zip: # Production build
npx webpack --mode production 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 @rm -rf riichi
mkdir riichi @mkdir riichi
cp -r build riichi @cp -r build riichi/
cp index.html riichi @cp index.html riichi/
cp -r img riichi @cp -r img riichi/
zip -r riichi.zip riichi @cp -r src/styles riichi/src/ 2>/dev/null || mkdir -p riichi/src && cp -r src/styles riichi/src/
@rm -r riichi @zip -r riichi.zip riichi
@rm -rf riichi
@echo "Created riichi.zip"
# Clean build artifacts
clean: clean:
rm -rf build/ @echo "Cleaning build artifacts..."
rm -f riichi.zip @rm -rf build/
@rm -rf dist/
@rm -f riichi.zip
@rm -rf riichi/
hard-clean: # Deep clean (including dependencies)
rm -rf build/ hard-clean: clean
rm -rf node_modules/ @echo "Deep cleaning (including dependencies)..."
rm -f package-lock.json @rm -rf node_modules/
rm -f richi.zip @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 ""

136
README.md
View file

@ -1,83 +1,127 @@
# 🀄 Tutoriel interactif de Mahjong 🀄 # Tutoriel interactif de Mahjong Riichi
Un tutoriel complet et interactif pour apprendre à jouer au mahjong, développé en TypeScript. Ce projet vise à rendre l'apprentissage du mahjong accessible à tous.
<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/). 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 | Technologie | Version | Usage |
- Interface interactive: Manipulez les tuiles et expérimenter les dynamiques |------------|---------|-------|
- Exemples de jeu: Scénarios dirigés pour s'entrainer aux différentes situations | TypeScript | 5.6+ | Langage principal |
- Non compatible pour le format téléphone ou tablette | 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 ### Installation
- Mode strict activé
- Module de type "esnext"
```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 ## Scripts Disponibles
- webpack ^5.98.0
- webpack-cli ^6.0.1
| 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``` ```bash
- Installation des dépendances: ```npm install``` make dev # Serveur de développement
- Compilation: ```make``` make build # Build production
make preview # Prévisualiser le build
make test # Lancer les tests
make clean # Nettoyer les fichiers générés
make hard-clean # Nettoyage complet (incluant node_modules)
make zip # Créer une archive de distribution
make help # Afficher l'aide
```
## Structure du Projet
## Ⓜ️ Makefile
Un makefile est à disposition pour exécuter plusieurs commandes:
- `make` compile le projet dans `build/` en mode `production`
- `make dev` compile le projet dans `build/` en mode `development`
- `make zip` compresse le contenu du `build` et les ressource nécessaires dans `riichi.zip`
- `make clean` supprime `riichi.zip` et le contenu de `build/`
- `make hard-clean` en plus de clean, supprime toutes les dépendances installées
## 📁 Structure du projet
``` ```
Riichi/ Riichi/
├── src/ ├── src/
│ ├── img/ # Dossier contenant les images │ ├── constants/ # Constantes partagées
│ ├── display/ # Composant gérant l'affichage de chaque section │ ├── display/ # Composants d'affichage (dp*.ts)
│ ├── styles/ # Feuilles de style CSS
│ ├── text/ # Textes des tutoriels │ ├── text/ # Textes des tutoriels
│ ├── yakus/ # Implémentation des yakus │ ├── types/ # Définitions de types TypeScript
│ └── [autres fichiers sources] │ ├── utils/ # Fonctions utilitaires
│ └── yakus/ # Implémentation des yakus
├── img/ # Images et ressources ├── img/ # Images et ressources
├── tests/ # Tests unitaires et d'intégration ├── tests/ # Tests unitaires
├── build/ # Ensemble du projet compilé en JavaScript ├── build/ # Fichiers compilés
└── [autres fichiers de configuration] ├── 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).

View file

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

View file

@ -1,8 +1,38 @@
{ {
"name": "riichi-mahjong-tutorial",
"version": "2.0.0",
"description": "Tutoriel interactif de Mahjong Riichi en TypeScript",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"ts-loader": "^9.5.2", "terser": "^5.44.1",
"webpack": "^5.98.0", "typescript": "^5.6.0",
"webpack-cli": "^6.0.1" "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"
} }
} }

216
src/constants/index.ts Normal file
View file

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

View file

@ -1,4 +1,4 @@
import { Deck } from "../deck" import { Deck } from "../deck";
declare global { declare global {
interface Window { interface Window {
@ -21,11 +21,11 @@ async function display() {
if (ctx) { if (ctx) {
// tapis // tapis
ctx.fillStyle = "#007730"; ctx.fillStyle = "#007730";
ctx.fillRect(50, 50, 1000, 1000); ctx.fillRect(0, 0, 1000, 1000);
// tuiles // tuiles
let x = 180; let x = 130;
let y = 80; let y = 30;
let size = 0.75; let size = 0.75;
let xos = 40; let xos = 40;
let yos = 100; let yos = 100;
@ -39,11 +39,11 @@ async function display() {
let delta = 50 * size; let delta = 50 * size;
let eps = 30 * size; let eps = 30 * size;
ctx.fillText("Caractère:", 55, y + delta + eps); ctx.fillText("Caractère:", 5, y + delta + eps);
ctx.fillText("Rond:", 55, y + 3 * delta + yos * size + eps); ctx.fillText("Rond:", 5, y + 3 * delta + yos * size + eps);
ctx.fillText("Bambou:", 55, y + 5 * delta + 2 * yos * size + eps); ctx.fillText("Bambou:", 5, y + 5 * delta + 2 * yos * size + eps);
ctx.fillText("Vent:", 55, y + 7 * delta + 3 * yos * size + eps); ctx.fillText("Vent:", 5, y + 7 * delta + 3 * yos * size + eps);
ctx.fillText("Dragon:", 55, y + 9 * delta + 4 * yos * size + eps); ctx.fillText("Dragon:", 5, y + 9 * delta + 4 * yos * size + eps);
// familles // familles
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {

View file

@ -166,12 +166,12 @@ class RiichiDisplay {
// Draw background // Draw background
ctx.fillStyle = "#007730"; ctx.fillStyle = "#007730";
ctx.fillRect(50, 50, 1000, 1000); ctx.fillRect(0, 0, 1000, 1000);
// Draw title // Draw title
ctx.fillStyle = "#DFDFFF"; ctx.fillStyle = "#DFDFFF";
ctx.font = "50px serif"; ctx.font = "50px serif";
ctx.fillText("Exemples de main:", 65, 100); ctx.fillText("Exemples de main:", 15, 50);
// Draw example hands // Draw example hands
for (let i = 0; i < this.hands.length; i++) { for (let i = 0; i < this.hands.length; i++) {

View file

@ -1,11 +1,10 @@
import { Deck } from "../deck"; import { Deck } from "../deck";
import { Hand } from "../hand"; import { Hand } from "../hand";
import { Group } from "../group"
// Configuration globale // Configuration globale
const CANVAS_ID = "myCanvas"; const CANVAS_ID = "myCanvas";
const BG_COLOR = "#007730"; const BG_COLOR = "#007730";
const BG_RECT = { x: 50, y: 50, w: 1000, h: 1000 }; const BG_RECT = { x: 0, y: 0, w: 1000, h: 1000 };
const FPS = 30; const FPS = 30;
const FRAME_INTERVAL = 1000 / FPS; const FRAME_INTERVAL = 1000 / FPS;

562
src/styles/main.css Normal file
View file

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

149
src/types/index.ts Normal file
View file

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

335
src/utils/index.ts Normal file
View file

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

View file

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

99
vite.config.ts Normal file
View file

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

View file

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