renew of the page
This commit is contained in:
parent
e9d16e8eaa
commit
0d2cb7919c
17 changed files with 1935 additions and 321 deletions
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
120
Makefile
120
Makefile
|
|
@ -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 ""
|
||||||
|
|
|
||||||
160
README.md
160
README.md
|
|
@ -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
|
|
||||||
- webpack ^5.98.0
|
|
||||||
- webpack-cli ^6.0.1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🚀 Installation
|
|
||||||
|
|
||||||
- Clonage du dépot: ```git clone https://github.com/Didictateur/Riichi.git && cd Riichi```
|
|
||||||
- Installation des dépendances: ```npm install```
|
|
||||||
- Compilation: ```make```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Ⓜ️ Makefile
|
|
||||||
|
|
||||||
Un makefile est à disposition pour exécuter plusieurs commandes:
|
|
||||||
|
|
||||||
- `make` compile le projet dans `build/` en mode `production`
|
|
||||||
- `make dev` compile le projet dans `build/` en mode `development`
|
|
||||||
- `make zip` compresse le contenu du `build` et les ressource nécessaires dans `riichi.zip`
|
|
||||||
- `make clean` supprime `riichi.zip` et le contenu de `build/`
|
|
||||||
- `make hard-clean` en plus de clean, supprime toutes les dépendances installées
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 📁 Structure du projet
|
|
||||||
|
|
||||||
```
|
|
||||||
Riichi/
|
|
||||||
├── src/
|
|
||||||
│ ├── img/ # Dossier contenant les images
|
|
||||||
│ ├── display/ # Composant gérant l'affichage de chaque section
|
|
||||||
│ ├── text/ # Textes des tutoriels
|
|
||||||
│ ├── yakus/ # Implémentation des yakus
|
|
||||||
│ └── [autres fichiers sources]
|
|
||||||
├── img/ # Images et ressources
|
|
||||||
├── tests/ # Tests unitaires et d'intégration
|
|
||||||
├── build/ # Ensemble du projet compilé en JavaScript
|
|
||||||
└── [autres fichiers de configuration]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Scripts Disponibles
|
||||||
|
|
||||||
|
| Commande | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `npm run dev` | Lance le serveur de développement avec hot reload |
|
||||||
|
| `npm run build` | Compile le projet pour la production |
|
||||||
|
| `npm run preview` | Prévisualise le build de production |
|
||||||
|
| `npm run typecheck` | Vérifie les types TypeScript |
|
||||||
|
| `npm run test` | Lance les tests |
|
||||||
|
| `npm run test:watch` | Lance les tests en mode watch |
|
||||||
|
|
||||||
## 🤝 Source
|
## Makefile
|
||||||
|
|
||||||
Les images ont été récupérées depuis le github de [FluffyStuff](https://github.com/FluffyStuff/riichi-mahjong-tiles?tab=readme-ov-file).
|
Un Makefile est également disponible :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev # Serveur de développement
|
||||||
|
make build # Build production
|
||||||
|
make preview # Prévisualiser le build
|
||||||
|
make test # Lancer les tests
|
||||||
|
make clean # Nettoyer les fichiers générés
|
||||||
|
make hard-clean # Nettoyage complet (incluant node_modules)
|
||||||
|
make zip # Créer une archive de distribution
|
||||||
|
make help # Afficher l'aide
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure du Projet
|
||||||
|
|
||||||
## 📝 Licence
|
```
|
||||||
|
Riichi/
|
||||||
|
├── src/
|
||||||
|
│ ├── constants/ # Constantes partagées
|
||||||
|
│ ├── display/ # Composants d'affichage (dp*.ts)
|
||||||
|
│ ├── styles/ # Feuilles de style CSS
|
||||||
|
│ ├── text/ # Textes des tutoriels
|
||||||
|
│ ├── types/ # Définitions de types TypeScript
|
||||||
|
│ ├── utils/ # Fonctions utilitaires
|
||||||
|
│ └── yakus/ # Implémentation des yakus
|
||||||
|
├── img/ # Images et ressources
|
||||||
|
├── tests/ # Tests unitaires
|
||||||
|
├── build/ # Fichiers compilés
|
||||||
|
├── index.html # Point d'entrée HTML
|
||||||
|
├── vite.config.ts # Configuration Vite
|
||||||
|
└── tsconfig.json # Configuration TypeScript
|
||||||
|
```
|
||||||
|
|
||||||
Tout le projet est dans le [domaine public](https://creativecommons.org/publicdomain/zero/1.0/).
|
## Design System
|
||||||
|
|
||||||
|
Le projet utilise un système de variables CSS moderne :
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--color-primary: #1a7f4b;
|
||||||
|
--color-accent: #ffd700;
|
||||||
|
--bg-main: linear-gradient(...);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contribution
|
||||||
|
|
||||||
|
Les contributions sont les bienvenues ! N'hésitez pas à :
|
||||||
|
1. Fork le projet
|
||||||
|
2. Créer une branche (`git checkout -b feature/amelioration`)
|
||||||
|
3. Commit vos changements (`git commit -am 'Ajout de fonctionnalité'`)
|
||||||
|
4. Push la branche (`git push origin feature/amelioration`)
|
||||||
|
5. Ouvrir une Pull Request
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
Les images des tuiles proviennent du dépôt de [FluffyStuff](https://github.com/FluffyStuff/riichi-mahjong-tiles).
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Ce projet est dans le [domaine public](https://creativecommons.org/publicdomain/zero/1.0/) (CC0 1.0).
|
||||||
|
|
|
||||||
400
index.html
400
index.html
|
|
@ -2,190 +2,260 @@
|
||||||
<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 -->
|
||||||
</div>
|
<li class="navbar__item">
|
||||||
|
<a href="#" class="navbar__link navbar__link--dropdown">
|
||||||
|
Riichi Mahjong
|
||||||
|
</a>
|
||||||
|
<div class="dropdown" role="menu">
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp1.js'); return false;" role="menuitem">
|
||||||
|
Chap 1: Présentation des tuiles
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp2.js'); return false;" role="menuitem">
|
||||||
|
Chap 2: La main
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp3.js'); return false;" role="menuitem">
|
||||||
|
Chap 3: Les groupes
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp4.js'); return false;" role="menuitem">
|
||||||
|
Chap 4: Avoir une main valide en jeu
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp5.js'); return false;" role="menuitem">
|
||||||
|
Chap 5: Annoncer la victoire
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp6.js'); return false;" role="menuitem">
|
||||||
|
Chap 6: Le vent du joueur
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp7.js'); return false;" role="menuitem">
|
||||||
|
Chap 7: Les yakus
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp8.js'); return false;" role="menuitem">
|
||||||
|
Chap 8: Le riichi
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp9.js'); return false;" role="menuitem">
|
||||||
|
Chap 9: Le furiten
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp10.js'); return false;" role="menuitem">
|
||||||
|
Chap 10: Première partie complète
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp11.js'); return false;" role="menuitem">
|
||||||
|
Chap 11: Score: les Hans
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp12.js'); return false;" role="menuitem">
|
||||||
|
Chap 12: Score: les doras
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp13.js'); return false;" role="menuitem">
|
||||||
|
Chap 13: Score: les Fus
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp14.js'); return false;" role="menuitem">
|
||||||
|
Chap 14: Score: le calcul des points
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp15.js'); return false;" role="menuitem">
|
||||||
|
Chap 15: Partie avec les scores
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Partie réelle -->
|
||||||
|
<li class="navbar__item">
|
||||||
|
<a href="#" class="navbar__link navbar__link--dropdown">
|
||||||
|
Partie réelle
|
||||||
|
</a>
|
||||||
|
<div class="dropdown" role="menu">
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp16.js'); return false;" role="menuitem">
|
||||||
|
Chap 1: Placement des joueurs
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp17.js'); return false;" role="menuitem">
|
||||||
|
Chap 2: Construction des murs
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp18.js'); return false;" role="menuitem">
|
||||||
|
Chap 3: Désigner la pioche
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp19.js'); return false;" role="menuitem">
|
||||||
|
Chap 4: Le mur mort
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Riichi à trois -->
|
||||||
|
<li class="navbar__item">
|
||||||
|
<a href="#" class="navbar__link" onclick="loadScript('dp20.js'); return false;">
|
||||||
|
Riichi à 3
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Yaku trainer -->
|
||||||
|
<li class="navbar__item">
|
||||||
|
<a href="#" class="navbar__link navbar__link--dropdown">
|
||||||
|
Yaku Trainer
|
||||||
|
</a>
|
||||||
|
<div class="dropdown" role="menu">
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp21.js'); return false;" role="menuitem">
|
||||||
|
Entraînement Yaku 1
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- MCR -->
|
||||||
|
<li class="navbar__item">
|
||||||
|
<a href="#" class="navbar__link navbar__link--dropdown">
|
||||||
|
MCR
|
||||||
|
</a>
|
||||||
|
<div class="dropdown" role="menu">
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp22.js'); return false;" role="menuitem">
|
||||||
|
Chapitre 1
|
||||||
|
</a>
|
||||||
|
<a href="#" class="dropdown__link dropdown__link--chapter" onclick="loadScript('dp23.js'); return false;" role="menuitem">
|
||||||
|
Chapitre 2
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
</ul>
|
</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>
|
</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">
|
||||||
</div>
|
<canvas id="myCanvas" width="1000" height="1000"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="anotherCanvasContainer" class="canvas-wrapper animate-fadeIn">
|
||||||
|
<canvas id="myTextCanvas" width="1000" height="1000"></canvas>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<!-- Application Script -->
|
||||||
let currentScript = null;
|
<script type="module">
|
||||||
|
// State management
|
||||||
|
const AppState = {
|
||||||
|
currentScript: null,
|
||||||
|
lastLoadedScript: 'dp0.js',
|
||||||
|
lastTextMode: true
|
||||||
|
};
|
||||||
|
|
||||||
function removePlayButton() {
|
// Utility functions
|
||||||
const btn = document.getElementById("playButton");
|
function removePlayButton() {
|
||||||
if (btn) btn.remove();
|
const btn = document.getElementById("playButton");
|
||||||
}
|
if (btn) btn.remove();
|
||||||
|
}
|
||||||
function loadText(scriptName, nb) {
|
|
||||||
if (window.cleanup) {
|
|
||||||
window.cleanup();
|
|
||||||
}
|
|
||||||
removePlayButton();
|
|
||||||
window.txtNumber = nb;
|
|
||||||
|
|
||||||
const container = document.getElementById("anotherCanvasContainer");
|
function generateTimestamp() {
|
||||||
container.innerHTML = `<canvas id="myTextCanvas" width="1000" height="1000"></canvas>`;
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = new Date().getTime();
|
function cleanupPreviousState() {
|
||||||
|
if (window.cleanup) {
|
||||||
|
window.cleanup();
|
||||||
|
}
|
||||||
|
removePlayButton();
|
||||||
|
}
|
||||||
|
|
||||||
currentScript = document.createElement("script");
|
// Load text content
|
||||||
currentScript.type = "module";
|
function loadText(scriptName, nb) {
|
||||||
currentScript.src = `build/${scriptName}?t=${timestamp}`;
|
cleanupPreviousState();
|
||||||
|
window.txtNumber = nb;
|
||||||
|
|
||||||
document.body.appendChild(currentScript);
|
const container = document.getElementById("anotherCanvasContainer");
|
||||||
}
|
container.innerHTML = `
|
||||||
|
<canvas id="myTextCanvas" width="1000" height="1000"></canvas>
|
||||||
let lastLoadedScript = 'dp0.js';
|
`;
|
||||||
let lastTextMode = true;
|
|
||||||
|
|
||||||
function loadScript(scriptName, txt = true) {
|
const script = document.createElement("script");
|
||||||
if (window.cleanup) {
|
script.type = "module";
|
||||||
window.cleanup();
|
script.src = `build/${scriptName}?t=${generateTimestamp()}`;
|
||||||
}
|
document.body.appendChild(script);
|
||||||
removePlayButton();
|
|
||||||
|
AppState.currentScript = script;
|
||||||
|
}
|
||||||
|
|
||||||
const container = document.getElementById("canvasContainer");
|
// Load main script
|
||||||
container.innerHTML = `<canvas id="myCanvas" width="1000" height="1000"></canvas>`;
|
function loadScript(scriptName, txt = true) {
|
||||||
|
cleanupPreviousState();
|
||||||
|
|
||||||
const timestamp = new Date().getTime();
|
const mainContainer = document.querySelector('.main-container');
|
||||||
|
const container = document.getElementById("canvasContainer");
|
||||||
|
const textContainer = document.getElementById("anotherCanvasContainer");
|
||||||
|
|
||||||
|
// Mode introduction (dp0) : canvas plus grand
|
||||||
|
const isIntro = scriptName === 'dp0.js';
|
||||||
|
|
||||||
|
if (isIntro) {
|
||||||
|
mainContainer.classList.add('intro-mode');
|
||||||
|
container.innerHTML = `
|
||||||
|
<canvas id="myCanvas" width="1000" height="1000"></canvas>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
mainContainer.classList.remove('intro-mode');
|
||||||
|
container.innerHTML = `
|
||||||
|
<canvas id="myCanvas" width="1000" height="1000"></canvas>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
currentScript = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
currentScript.type = "module";
|
script.type = "module";
|
||||||
currentScript.src = `build/${scriptName}?t=${timestamp}`;
|
script.src = `build/${scriptName}?t=${generateTimestamp()}`;
|
||||||
|
document.body.appendChild(script);
|
||||||
|
|
||||||
document.body.appendChild(currentScript);
|
AppState.lastLoadedScript = scriptName;
|
||||||
|
AppState.lastTextMode = txt;
|
||||||
lastLoadedScript = scriptName;
|
AppState.currentScript = script;
|
||||||
lastTextMode = txt;
|
|
||||||
|
|
||||||
if (txt) {
|
if (txt) {
|
||||||
const number = scriptName.substring(2, scriptName.length - 3);
|
const number = scriptName.substring(2, scriptName.length - 3);
|
||||||
loadText("txt.js", number);
|
loadText("txt.js", number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loadScript('dp0.js');
|
// Expose to global scope for onclick handlers
|
||||||
|
window.loadScript = loadScript;
|
||||||
|
window.loadText = loadText;
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
loadScript('dp0.js');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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": {
|
"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
216
src/constants/index.ts
Normal 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;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Deck } from "../deck"
|
import { Deck } from "../deck";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -6,7 +6,7 @@ declare global {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export { };
|
||||||
|
|
||||||
async function preloadDeck(deck: Deck) {
|
async function preloadDeck(deck: Deck) {
|
||||||
await deck.preload();
|
await deck.preload();
|
||||||
|
|
@ -18,14 +18,14 @@ async function display() {
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
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++) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ declare global {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export { };
|
||||||
|
|
||||||
class RiichiDisplay {
|
class RiichiDisplay {
|
||||||
private canvas: HTMLCanvasElement;
|
private canvas: HTMLCanvasElement;
|
||||||
|
|
@ -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++) {
|
||||||
|
|
|
||||||
|
|
@ -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
562
src/styles/main.css
Normal 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
149
src/types/index.ts
Normal 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
335
src/utils/index.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
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