This commit is contained in:
Gilles Soulier
2025-12-27 21:18:23 +01:00
parent 0b5b3201e4
commit e95f6bb052
103 changed files with 2523 additions and 226 deletions

View File

@@ -7,6 +7,11 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-fullscreen"> <meta name="apple-mobile-web-app-status-bar-style" content="black-fullscreen">
<meta name="theme-color" content="#000000"> <meta name="theme-color" content="#000000">
<!-- Force landscape orientation for PWA -->
<meta name="screen-orientation" content="landscape">
<meta name="x5-orientation" content="landscape">
<meta name="full-screen" content="yes">
<meta name="x5-fullscreen" content="true">
<title>Mario Runner - Jeu Mobile</title> <title>Mario Runner - Jeu Mobile</title>
<link rel="shortcut icon" href="/icons/favicon.ico"> <link rel="shortcut icon" href="/icons/favicon.ico">
@@ -47,33 +52,6 @@
align-items: center; align-items: center;
} }
/* Forcer l'orientation paysage */
@media screen and (orientation: portrait) {
#game-container::before {
content: '↻';
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 80px;
color: white;
z-index: 9999;
}
#game-container::after {
content: 'Veuillez tourner votre téléphone en mode paysage';
position: fixed;
top: 60%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-family: Arial, sans-serif;
font-size: 18px;
text-align: center;
padding: 0 20px;
z-index: 9999;
}
}
canvas { canvas {
display: block; display: block;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

BIN
public/assets/images/user1.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
public/assets/images/user2.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/assets/sprites/dog/dog.xcf Executable file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

View File

@@ -4,7 +4,7 @@
"description": "Jeu de plateforme mobile avec contrôle gyroscope", "description": "Jeu de plateforme mobile avec contrôle gyroscope",
"start_url": "/", "start_url": "/",
"display": "fullscreen", "display": "fullscreen",
"orientation": "landscape", "orientation": "landscape-primary",
"background_color": "#000000", "background_color": "#000000",
"theme_color": "#4CAF50", "theme_color": "#4CAF50",
"icons": [ "icons": [

88
resize-user1-sprites.cjs Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Script pour redimensionner les sprites user1 à 93x224px (même taille que user2)
* Utilise le package sharp pour le traitement d'images
*/
const fs = require('fs');
const path = require('path');
// Vérifier si sharp est disponible
let sharp;
try {
sharp = require('sharp');
} catch (err) {
console.log('❌ Le package "sharp" n\'est pas installé.');
console.log('📦 Installation en cours...');
const { execSync } = require('child_process');
try {
execSync('npm install --no-save sharp', { stdio: 'inherit' });
sharp = require('sharp');
console.log('✅ Sharp installé avec succès!\n');
} catch (installErr) {
console.error('❌ Impossible d\'installer sharp. Veuillez l\'installer manuellement:');
console.error(' npm install sharp');
process.exit(1);
}
}
const USER1_DIR = path.join(__dirname, 'public/assets/sprites/user1');
const BACKUP_DIR = path.join(__dirname, 'public/assets/sprites/user1_backup');
const TARGET_WIDTH = 93;
const TARGET_HEIGHT = 224;
async function resizeImages() {
try {
// Créer un backup
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
console.log('📁 Dossier de backup créé:', BACKUP_DIR);
}
// Lire tous les fichiers PNG sauf le spritesheet
const files = fs.readdirSync(USER1_DIR)
.filter(f => f.endsWith('.png') && f !== 'player_spritesheet.png');
console.log(`\n🖼️ Redimensionnement de ${files.length} sprites vers ${TARGET_WIDTH}x${TARGET_HEIGHT}px...\n`);
for (const file of files) {
const inputPath = path.join(USER1_DIR, file);
const backupPath = path.join(BACKUP_DIR, file);
// Backup de l'original
if (!fs.existsSync(backupPath)) {
fs.copyFileSync(inputPath, backupPath);
}
// Lire les métadonnées de l'image
const metadata = await sharp(inputPath).metadata();
console.log(`📏 ${file}`);
console.log(` ${metadata.width}x${metadata.height}${TARGET_WIDTH}x${TARGET_HEIGHT}`);
// Redimensionner avec fit: 'contain' pour préserver le ratio et ajouter fond transparent
await sharp(inputPath)
.resize(TARGET_WIDTH, TARGET_HEIGHT, {
kernel: 'lanczos3',
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.toFile(inputPath + '.tmp');
// Remplacer l'original
fs.renameSync(inputPath + '.tmp', inputPath);
console.log(` ✅ Redimensionné\n`);
}
console.log('✨ Toutes les images ont été redimensionnées avec succès!');
console.log(`💾 Les originaux sont sauvegardés dans: ${BACKUP_DIR}`);
} catch (error) {
console.error('❌ Erreur:', error.message);
process.exit(1);
}
}
// Exécuter
resizeImages();

88
resize-user2-sprites.cjs Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Script pour redimensionner les sprites user2 de 25%
* Utilise le package sharp pour le traitement d'images
*/
const fs = require('fs');
const path = require('path');
// Vérifier si sharp est disponible
let sharp;
try {
sharp = require('sharp');
} catch (err) {
console.log('❌ Le package "sharp" n\'est pas installé.');
console.log('📦 Installation en cours...');
const { execSync } = require('child_process');
try {
execSync('npm install --no-save sharp', { stdio: 'inherit' });
sharp = require('sharp');
console.log('✅ Sharp installé avec succès!\n');
} catch (installErr) {
console.error('❌ Impossible d\'installer sharp. Veuillez l\'installer manuellement:');
console.error(' npm install sharp');
process.exit(1);
}
}
const USER2_DIR = path.join(__dirname, 'public/assets/sprites/user2');
const BACKUP_DIR = path.join(__dirname, 'public/assets/sprites/user2_backup');
const TARGET_WIDTH = 93;
const TARGET_HEIGHT = 224;
async function resizeImages() {
try {
// Créer un backup
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
console.log('📁 Dossier de backup créé:', BACKUP_DIR);
}
// Lire tous les fichiers PNG sauf le spritesheet
const files = fs.readdirSync(USER2_DIR)
.filter(f => f.endsWith('.png') && f !== 'player_spritesheet.png');
console.log(`\n🖼️ Redimensionnement de ${files.length} sprites vers ${TARGET_WIDTH}x${TARGET_HEIGHT}px...\n`);
for (const file of files) {
const inputPath = path.join(USER2_DIR, file);
const backupPath = path.join(BACKUP_DIR, file);
// Backup de l'original
if (!fs.existsSync(backupPath)) {
fs.copyFileSync(inputPath, backupPath);
}
// Lire les métadonnées de l'image
const metadata = await sharp(inputPath).metadata();
console.log(`📏 ${file}`);
console.log(` ${metadata.width}x${metadata.height}${TARGET_WIDTH}x${TARGET_HEIGHT}`);
// Redimensionner avec fit: 'contain' pour préserver le ratio et ajouter fond transparent
await sharp(inputPath)
.resize(TARGET_WIDTH, TARGET_HEIGHT, {
kernel: 'lanczos3',
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.toFile(inputPath + '.tmp');
// Remplacer l'original
fs.renameSync(inputPath + '.tmp', inputPath);
console.log(` ✅ Redimensionné\n`);
}
console.log('✨ Toutes les images ont été redimensionnées avec succès!');
console.log(`💾 Les originaux sont sauvegardés dans: ${BACKUP_DIR}`);
} catch (error) {
console.error('❌ Erreur:', error.message);
process.exit(1);
}
}
// Exécuter
resizeImages();

View File

@@ -1,14 +1,97 @@
import { GYRO_DEADZONE, GYRO_MAX_TILT, GYRO_SENSITIVITY } from '../utils/constants'; import {
GYRO_DEADZONE_ANDROID,
GYRO_MAX_TILT_ANDROID,
GYRO_SENSITIVITY_ANDROID,
GYRO_CALIBRATION_SAMPLES_ANDROID,
GYRO_INVERT_BETA_ANDROID,
GYRO_USE_GAMMA_ANDROID,
GYRO_USE_ALPHA_ANDROID,
GYRO_DEADZONE_IOS,
GYRO_MAX_TILT_IOS,
GYRO_SENSITIVITY_IOS,
GYRO_CALIBRATION_SAMPLES_IOS,
GYRO_INVERT_BETA_IOS,
GYRO_USE_GAMMA_IOS,
GYRO_USE_ALPHA_IOS,
} from '../utils/constants';
/** /**
* Gestion du gyroscope pour iOS et Android * Gestion du gyroscope pour iOS et Android
* Retourne une valeur normalisée entre -1 et 1 * Retourne une valeur normalisée entre -1 et 1
*
* Configuration centralisée dans constants.ts pour faciliter le débogage:
* - iOS: GYRO_*_IOS (sensibilité réduite, pas d'inversion beta)
* - Android: GYRO_*_ANDROID (sensibilité élevée, inversion beta)
*
* Pour ajuster les contrôles, modifier les constantes dans utils/constants.ts
*/ */
export class GyroControl { export class GyroControl {
private tiltValue: number = 0; private tiltValue: number = 0;
private isActive: boolean = false; private isActive: boolean = false;
private lastEventLogTime: number = 0;
private lastTiltLogTime: number = 0;
private lastAlpha: number = 0; // Rotation boussole (0-360°)
private lastBeta: number = 0; // Inclinaison avant/arrière
private lastGamma: number = 0; // Inclinaison gauche/droite
private lastAngle: number = 0;
private lastAbs: boolean | null = null;
private lastEventAt: number = 0;
private baselineTilt: number = 0; // Baseline pour calibration
private isCalibrated: boolean = false;
private calibrationSamples: number[] = [];
private readonly calibrationSamplesCount: number; // Nombre d'échantillons pour moyenne (varie selon plateforme)
// Configuration spécifique selon la plateforme
private readonly isIOS: boolean;
private readonly deadzone: number;
private readonly maxTilt: number;
private readonly sensitivity: number;
private readonly invertBeta: boolean; // Inverser la lecture du beta?
private readonly useGamma: boolean; // Utiliser gamma au lieu de beta en paysage?
private readonly useAlpha: boolean; // Utiliser alpha (rotation boussole)?
constructor() { constructor() {
// Détecter iOS
this.isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// Configurer selon la plateforme (toutes les valeurs viennent de constants.ts)
if (this.isIOS) {
this.deadzone = GYRO_DEADZONE_IOS;
this.maxTilt = GYRO_MAX_TILT_IOS;
this.sensitivity = GYRO_SENSITIVITY_IOS;
this.calibrationSamplesCount = GYRO_CALIBRATION_SAMPLES_IOS;
this.invertBeta = GYRO_INVERT_BETA_IOS;
this.useGamma = GYRO_USE_GAMMA_IOS;
this.useAlpha = GYRO_USE_ALPHA_IOS;
console.log('[GyroControl] ✅ Configuration iOS chargée depuis constants.ts', {
deadzone: this.deadzone,
maxTilt: this.maxTilt,
sensitivity: this.sensitivity,
calibrationSamples: this.calibrationSamplesCount,
invertBeta: this.invertBeta,
useGamma: this.useGamma,
useAlpha: this.useAlpha
});
} else {
this.deadzone = GYRO_DEADZONE_ANDROID;
this.maxTilt = GYRO_MAX_TILT_ANDROID;
this.sensitivity = GYRO_SENSITIVITY_ANDROID;
this.calibrationSamplesCount = GYRO_CALIBRATION_SAMPLES_ANDROID;
this.invertBeta = GYRO_INVERT_BETA_ANDROID;
this.useGamma = GYRO_USE_GAMMA_ANDROID;
this.useAlpha = GYRO_USE_ALPHA_ANDROID;
console.log('[GyroControl] ✅ Configuration Android chargée depuis constants.ts', {
deadzone: this.deadzone,
maxTilt: this.maxTilt,
sensitivity: this.sensitivity,
calibrationSamples: this.calibrationSamplesCount,
invertBeta: this.invertBeta,
useGamma: this.useGamma,
useAlpha: this.useAlpha
});
}
this.setupGyroscope(); this.setupGyroscope();
} }
@@ -46,33 +129,130 @@ export class GyroControl {
// Déterminer l'axe horizontal en fonction de l'orientation écran // Déterminer l'axe horizontal en fonction de l'orientation écran
const angle = (window.screen.orientation?.angle ?? (window as any).orientation ?? 0) as number; const angle = (window.screen.orientation?.angle ?? (window as any).orientation ?? 0) as number;
const alpha = event.alpha ?? 0; // rotation boussole (0-360°)
const beta = event.beta ?? 0; // inclinaison avant/arrière const beta = event.beta ?? 0; // inclinaison avant/arrière
const gamma = event.gamma ?? 0; // inclinaison gauche/droite const gamma = event.gamma ?? 0; // inclinaison gauche/droite
const now = Date.now();
// En paysage, la gauche/droite correspond à beta; en portrait, à gamma this.lastAlpha = alpha;
let horizontalTiltDeg = gamma; this.lastBeta = beta;
if (angle === 90) { this.lastGamma = gamma;
horizontalTiltDeg = -beta; this.lastAngle = angle;
} else if (angle === -90 || angle === 270) { this.lastAbs = event.absolute ?? null;
horizontalTiltDeg = beta; this.lastEventAt = now;
if (now - this.lastEventLogTime > 1000) {
console.log('[Gyro] event', {
angle,
alpha,
beta,
gamma,
abs: event.absolute ?? null,
});
this.lastEventLogTime = now;
} }
const relativeTilt = horizontalTiltDeg; // IMPORTANT : Déterminer l'axe à utiliser selon l'orientation réelle
// iOS: beta (avant/arrière), Android: peut utiliser alpha (rotation boussole), gamma, ou beta
let horizontalTiltDeg: number;
// Appliquer la deadzone // Si on utilise alpha (rotation boussole - Android uniquement)
if (Math.abs(relativeTilt) < GYRO_DEADZONE) { if (this.useAlpha) {
// Alpha va de 0 à 360°
// Convertir en -180 à 180 pour avoir une baseline centrée
let normalizedAlpha = alpha;
if (normalizedAlpha > 180) {
normalizedAlpha -= 360;
}
horizontalTiltDeg = normalizedAlpha;
}
// ANDROID : Forcer l'utilisation de BETA quelle que soit l'orientation
else if (!this.isIOS && !this.useGamma && !this.useAlpha) {
// Android avec configuration BETA : utiliser beta directement
horizontalTiltDeg = this.invertBeta ? -beta : beta;
// Ajuster selon l'orientation du téléphone
// Si angle = 90° (paysage avec home button à droite) ou -90° (home button à gauche)
if (Math.abs(angle - 90) < 45) {
// Paysage normal (90°) : pas d'inversion supplémentaire
// horizontalTiltDeg reste tel quel
} else if (Math.abs(angle + 90) < 45 || Math.abs(angle - 270) < 45) {
// Paysage inversé (-90° ou 270°) : inverser
horizontalTiltDeg = -horizontalTiltDeg;
}
}
// Sinon, si angle est proche de 0° ou 180° (mode paysage classique)
else if (Math.abs(angle) < 45 || Math.abs(angle - 180) < 45 || Math.abs(angle + 180) < 45) {
// Mode paysage
if (this.useGamma) {
// Android : utiliser gamma pour l'inclinaison gauche/droite
horizontalTiltDeg = gamma;
// Inverser si paysage inversé (angle proche de 180)
if (Math.abs(angle - 180) < 45 || Math.abs(angle + 180) < 45) {
horizontalTiltDeg = -horizontalTiltDeg;
}
} else {
// iOS : utiliser beta pour l'inclinaison avant/arrière
// L'inversion du beta est configurée dans constants.ts selon la plateforme
horizontalTiltDeg = this.invertBeta ? -beta : beta;
// Inverser si paysage inversé (angle proche de 180)
if (Math.abs(angle - 180) < 45 || Math.abs(angle + 180) < 45) {
horizontalTiltDeg = -horizontalTiltDeg;
}
}
}
// Si angle proche de 90° ou -90°/270° (mode portrait)
else {
// Mode portrait : utiliser gamma pour l'inclinaison gauche/droite
horizontalTiltDeg = gamma;
// Ajuster le signe selon l'orientation
if (angle < 0 || angle > 180) {
horizontalTiltDeg = -gamma;
}
}
// Calibration automatique : collecter les premiers échantillons
if (!this.isCalibrated) {
this.calibrationSamples.push(horizontalTiltDeg);
if (this.calibrationSamples.length >= this.calibrationSamplesCount) {
// Calculer la moyenne pour établir la baseline
this.baselineTilt = this.calibrationSamples.reduce((sum, val) => sum + val, 0) / this.calibrationSamples.length;
this.isCalibrated = true;
console.log(`[Gyro ${this.isIOS ? 'iOS' : 'Android'}] ✅ Calibration terminée - Baseline: ${this.baselineTilt.toFixed(2)}° (${this.calibrationSamplesCount} échantillons)`);
} else {
console.log(`[Gyro ${this.isIOS ? 'iOS' : 'Android'}] Calibration en cours... (${this.calibrationSamples.length}/${this.calibrationSamplesCount})`);
this.tiltValue = 0; // Pas de mouvement pendant la calibration
return;
}
}
// Soustraire la baseline pour obtenir le tilt relatif à la position neutre
const relativeTilt = horizontalTiltDeg - this.baselineTilt;
// Appliquer la deadzone (spécifique à la plateforme)
if (Math.abs(relativeTilt) < this.deadzone) {
this.tiltValue = 0; this.tiltValue = 0;
return; return;
} }
// Normaliser entre -1 et 1 // Normaliser entre -1 et 1 (avec maxTilt spécifique à la plateforme)
let normalizedTilt = relativeTilt / GYRO_MAX_TILT; let normalizedTilt = relativeTilt / this.maxTilt;
// Clamper entre -1 et 1 // Clamper entre -1 et 1
normalizedTilt = Math.max(-1, Math.min(1, normalizedTilt)); normalizedTilt = Math.max(-1, Math.min(1, normalizedTilt));
// Inversion gauche/droite (plus naturel selon retour) // Pas d'inversion finale - elle est déjà faite au niveau de la lecture du beta/gamma
this.tiltValue = -normalizedTilt; // iOS: beta direct → pencher en arrière = beta négatif → mouvement droite
// Android: gamma direct → pencher à droite = gamma positif → mouvement droite
this.tiltValue = normalizedTilt;
if (now - this.lastTiltLogTime > 1000) {
const axisUsed = this.useAlpha ? 'alpha' : (this.useGamma ? 'gamma' : 'beta');
console.log(`[Gyro ${this.isIOS ? 'iOS' : 'Android'}] tilt normalized`, this.tiltValue,
`(axe: ${axisUsed}, raw: ${horizontalTiltDeg.toFixed(1)}°, baseline: ${this.baselineTilt.toFixed(1)}°, relative: ${relativeTilt.toFixed(1)}°, deadzone: ${this.deadzone}°, maxTilt: ${this.maxTilt}°)`);
this.lastTiltLogTime = now;
}
} }
/** /**
@@ -83,18 +263,22 @@ export class GyroControl {
} }
/** /**
* Retourne la vitesse calculée depuis le tilt * Retourne la vitesse calculée depuis le tilt (avec sensibilité spécifique à la plateforme)
*/ */
public getVelocity(): number { public getVelocity(): number {
return this.tiltValue * GYRO_SENSITIVITY; return this.tiltValue * this.sensitivity;
} }
/** /**
* Calibre le gyroscope (définit l'orientation actuelle comme neutre) * Calibre le gyroscope (définit l'orientation actuelle comme neutre)
*/ */
public calibrate(): void { public calibrate(): void {
// Calibration simplifiée : pas de base dynamique, on recentre juste à 0 // Reset de la calibration pour recalculer la baseline
this.isCalibrated = false;
this.calibrationSamples = [];
this.baselineTilt = 0;
this.tiltValue = 0; this.tiltValue = 0;
console.log('[Gyro] Recalibration demandée...');
} }
/** /**
@@ -121,4 +305,47 @@ export class GyroControl {
this.isActive = false; this.isActive = false;
this.tiltValue = 0; this.tiltValue = 0;
} }
/**
* Debug info for on-screen diagnostics.
*/
public getDebugInfo(): {
active: boolean;
tilt: number;
alpha: number;
beta: number;
gamma: number;
angle: number;
abs: boolean | null;
lastEventAt: number;
calibrated: boolean;
baseline: number;
platform: string;
deadzone: number;
maxTilt: number;
sensitivity: number;
invertBeta: boolean;
useGamma: boolean;
useAlpha: boolean;
} {
return {
active: this.isActive,
tilt: this.tiltValue,
alpha: this.lastAlpha,
beta: this.lastBeta,
gamma: this.lastGamma,
angle: this.lastAngle,
abs: this.lastAbs,
lastEventAt: this.lastEventAt,
calibrated: this.isCalibrated,
baseline: this.baselineTilt,
platform: this.isIOS ? 'iOS' : 'Android',
deadzone: this.deadzone,
maxTilt: this.maxTilt,
sensitivity: this.sensitivity,
invertBeta: this.invertBeta,
useGamma: this.useGamma,
useAlpha: this.useAlpha,
};
}
} }

125
src/entities/Cage.ts Normal file
View File

@@ -0,0 +1,125 @@
import Phaser from 'phaser';
import { Dog } from './Dog';
/**
* Cage contenant le chien
* Peut être ouverte avec une clé
*/
export class Cage extends Phaser.GameObjects.Container {
public scene: Phaser.Scene;
private dog: Dog;
private door?: Phaser.GameObjects.Rectangle;
private bars: Phaser.GameObjects.Rectangle[] = [];
private isOpen: boolean = false;
private lockIcon?: Phaser.GameObjects.Text;
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y);
this.scene = scene;
scene.add.existing(this);
this.setDepth(49);
// Créer la cage visuellement
this.createCage();
// Créer le chien à l'intérieur
this.dog = new Dog(scene, x, y - 10, 150);
}
/**
* Crée la cage (barreaux, sol, porte)
*/
private createCage(): void {
const cageWidth = 180;
const cageHeight = 150;
// Sol de la cage
const floor = this.scene.add.rectangle(0, 10, cageWidth, 10, 0x8B4513);
this.add(floor);
// Toit
const roof = this.scene.add.rectangle(0, -cageHeight + 10, cageWidth, 8, 0x654321);
this.add(roof);
// Barreaux verticaux (fond)
const barCount = 8;
const barSpacing = cageWidth / barCount;
for (let i = 0; i < barCount; i++) {
const barX = -cageWidth / 2 + i * barSpacing + barSpacing / 2;
const bar = this.scene.add.rectangle(
barX,
-cageHeight / 2 + 10,
6,
cageHeight - 10,
0x555555
);
this.add(bar);
this.bars.push(bar);
}
// Porte (côté droit de la cage)
this.door = this.scene.add.rectangle(
cageWidth / 2 - 3,
-cageHeight / 2 + 10,
8,
cageHeight - 10,
0x333333
);
this.add(this.door);
// Icône de cadenas
this.lockIcon = this.scene.add.text(cageWidth / 2 - 20, -cageHeight / 2, '🔒', {
fontSize: '32px',
});
this.add(this.lockIcon);
}
/**
* Ouvre la cage
*/
public open(): void {
if (this.isOpen) return;
this.isOpen = true;
// Retirer le cadenas
this.lockIcon?.destroy();
// Animation d'ouverture de la porte
this.scene.tweens.add({
targets: this.door,
x: this.door!.x + 80,
angle: -90,
duration: 1000,
ease: 'Back.easeOut',
onComplete: () => {
// Libérer le chien
this.dog.free();
}
});
// Son d'ouverture (optionnel)
// this.scene.sound.play('cage-open');
}
/**
* Vérifie si la cage est ouverte
*/
public isOpened(): boolean {
return this.isOpen;
}
/**
* Retourne le chien
*/
public getDog(): Dog {
return this.dog;
}
/**
* Update pour le chien
*/
public update(): void {
this.dog.update();
}
}

194
src/entities/Dog.ts Normal file
View File

@@ -0,0 +1,194 @@
import Phaser from 'phaser';
/**
* Entité Dog - Petit chien en cage
* Fait des va-et-vient dans la cage avec alternance idle/marche
*/
export class Dog extends Phaser.GameObjects.Sprite {
public scene: Phaser.Scene;
private direction: number = 1; // 1 = droite, -1 = gauche
private moveTimer?: Phaser.Time.TimerEvent;
private idleTimer?: Phaser.Time.TimerEvent;
private cageLeft: number;
private cageRight: number;
private isMoving: boolean = false;
private isFree: boolean = false;
constructor(
scene: Phaser.Scene,
x: number,
y: number,
cageWidth: number = 150
) {
super(scene, x, y, 'dog-idle', 0);
this.scene = scene;
// Définir les limites de la cage
this.cageLeft = x - cageWidth / 2 + 20;
this.cageRight = x + cageWidth / 2 - 20;
// Ajouter au scene
scene.add.existing(this);
// Échelle du chien
this.setScale(0.8);
this.setOrigin(0.5, 1); // Origine aux pieds
// Depth
this.setDepth(50);
// Démarrer le comportement
this.startBehavior();
}
/**
* Démarre le comportement de va-et-vient
*/
private startBehavior(): void {
// Commencer par idle
this.playIdleAnimation();
// Après un temps aléatoire (2-4 sec), commencer à marcher
const idleDuration = Phaser.Math.Between(2000, 4000);
this.idleTimer = this.scene.time.delayedCall(idleDuration, () => {
this.startWalking();
});
}
/**
* Joue l'animation idle
*/
private playIdleAnimation(): void {
this.isMoving = false;
this.play('dog-idle', true);
}
/**
* Commence à marcher
*/
private startWalking(): void {
this.isMoving = true;
// Choisir aléatoirement la direction (sauf si on est contre un mur)
if (this.x <= this.cageLeft) {
this.direction = 1; // Forcer vers la droite
} else if (this.x >= this.cageRight) {
this.direction = -1; // Forcer vers la gauche
} else {
this.direction = Phaser.Math.Between(0, 1) === 0 ? -1 : 1;
}
// Si on change de direction, jouer l'animation de tour
if (this.direction === 1 && this.flipX) {
this.playTurnAnimation(() => {
this.flipX = false;
this.walk();
});
} else if (this.direction === -1 && !this.flipX) {
this.playTurnAnimation(() => {
this.flipX = true;
this.walk();
});
} else {
this.walk();
}
}
/**
* Marche dans la direction actuelle
*/
private walk(): void {
// Jouer l'animation de marche (walkback = marche vers la gauche en visuel)
this.play('dog-walkback', true);
// Marcher pendant 1-2 secondes
const walkDuration = Phaser.Math.Between(1000, 2000);
this.moveTimer = this.scene.time.delayedCall(walkDuration, () => {
this.stopWalking();
});
}
/**
* Arrête de marcher et retourne en idle
*/
private stopWalking(): void {
this.playIdleAnimation();
// Après un temps d'idle, recommencer à marcher
const idleDuration = Phaser.Math.Between(2000, 4000);
this.idleTimer = this.scene.time.delayedCall(idleDuration, () => {
if (!this.isFree) {
this.startWalking();
}
});
}
/**
* Joue l'animation de tour (demi-tour)
*/
private playTurnAnimation(onComplete: () => void): void {
this.play('dog-turn');
this.once('animationcomplete', () => {
onComplete();
});
}
/**
* Libère le chien (sort de la cage)
*/
public free(): void {
this.isFree = true;
this.isMoving = false;
// Annuler les timers
this.moveTimer?.destroy();
this.idleTimer?.destroy();
// Jouer une animation de joie (idle pour l'instant)
this.playIdleAnimation();
// Animation de sortie de cage (optionnel - peut courir vers la droite)
this.scene.tweens.add({
targets: this,
x: this.x + 200,
duration: 2000,
ease: 'Power2',
onStart: () => {
this.flipX = false;
this.play('dog-walkback', true);
},
onComplete: () => {
this.playIdleAnimation();
}
});
}
/**
* Méthode update pour le mouvement
*/
public update(): void {
if (!this.isMoving || this.isFree) return;
// Déplacer le chien
const speed = 20;
this.x += this.direction * speed * (1 / 60); // 60 FPS
// Vérifier les limites de la cage
if (this.x <= this.cageLeft) {
this.x = this.cageLeft;
this.stopWalking();
} else if (this.x >= this.cageRight) {
this.x = this.cageRight;
this.stopWalking();
}
}
/**
* Nettoyage
*/
public destroy(fromScene?: boolean): void {
this.moveTimer?.destroy();
this.idleTimer?.destroy();
super.destroy(fromScene);
}
}

View File

@@ -6,6 +6,8 @@ import {
PLAYER_ACCELERATION, PLAYER_ACCELERATION,
PLAYER_MAX_JUMPS, PLAYER_MAX_JUMPS,
RESPAWN_INVINCIBILITY_TIME, RESPAWN_INVINCIBILITY_TIME,
USER1_HITBOX,
USER2_HITBOX,
} from '../utils/constants'; } from '../utils/constants';
/** /**
@@ -19,11 +21,19 @@ export class Player extends Phaser.Physics.Arcade.Sprite {
private invincibilityTimer?: Phaser.Time.TimerEvent; private invincibilityTimer?: Phaser.Time.TimerEvent;
private animationsCreated: boolean = false; private animationsCreated: boolean = false;
private wasAir: boolean = false; private wasAir: boolean = false;
private playerPrefix: string = 'player_user1'; // Préfixe pour les sprites (user1 ou user2)
constructor(scene: Phaser.Scene, x: number, y: number) { constructor(scene: Phaser.Scene, x: number, y: number) {
// Pour l'instant, utiliser un sprite simple // Récupérer le joueur sélectionné depuis le registry
// TODO: Remplacer par le spritesheet du neveu const selectedPlayer = scene.registry.get('selectedPlayer') as string | undefined;
super(scene, x, y, 'player'); const playerPrefix = selectedPlayer === 'user2' ? 'player_user2' : 'player_user1';
// Utiliser le spritesheet comme texture de base
const spritesheetKey = selectedPlayer === 'user2' ? 'player_user2' : 'player_user1';
super(scene, x, y, spritesheetKey, 0); // Frame 0 du spritesheet
// Stocker le préfixe pour utilisation ultérieure
this.playerPrefix = playerPrefix;
// Ajouter à la scène // Ajouter à la scène
scene.add.existing(this); scene.add.existing(this);
@@ -34,35 +44,41 @@ export class Player extends Phaser.Physics.Arcade.Sprite {
body.setGravityY(PLAYER_GRAVITY); body.setGravityY(PLAYER_GRAVITY);
body.setCollideWorldBounds(true); // Collision avec les limites du monde body.setCollideWorldBounds(true); // Collision avec les limites du monde
body.onWorldBounds = true; // Active les événements de collision body.onWorldBounds = true; // Active les événements de collision
body.setSize(40, 70); // Hitbox
body.setMaxVelocity(PLAYER_MAX_SPEED, 1000); body.setMaxVelocity(PLAYER_MAX_SPEED, 1000);
// Aligner la hitbox en bas du sprite (80x169 par défaut)
body.setOffset((this.width - 40) / 2, (this.height || 169) - 70);
// Temporaire : créer un rectangle coloré si pas de texture
if (!scene.textures.exists('player')) {
this.createPlaceholderTexture(scene);
}
this.setOrigin(0.5, 1); // Origine en bas au centre this.setOrigin(0.5, 1); // Origine en bas au centre
body.setOffset((this.width - 40) / 2, (this.height || 169) - 70); // aligner la hitbox en bas this.setScale(1); // Échelle 1:1 pour les deux joueurs
// Utiliser la configuration de hitbox spécifique au joueur
const hitboxConfig = selectedPlayer === 'user2' ? USER2_HITBOX : USER1_HITBOX;
// Attendre que le sprite soit complètement initialisé pour configurer la hitbox
this.scene.time.delayedCall(0, () => {
const spriteWidth = this.width;
const spriteHeight = this.height;
// Centrer la hitbox horizontalement
const offsetX = (spriteWidth - hitboxConfig.width) / 2;
// Placer la hitbox en bas du sprite
const offsetY = spriteHeight - hitboxConfig.height;
body.setSize(hitboxConfig.width, hitboxConfig.height);
body.setOffset(offsetX, offsetY);
console.log(`🎯 Hitbox FINALE configurée pour ${this.playerPrefix}:`, {
selectedPlayer,
spriteActualSize: `${spriteWidth}x${spriteHeight}px`,
hitboxSize: `${hitboxConfig.width}x${hitboxConfig.height}px`,
offset: `x=${offsetX.toFixed(1)}, y=${offsetY.toFixed(1)}px`
});
});
this.ensureAnimations(); this.ensureAnimations();
this.setTexture('player_walk_1'); // frame par défaut
// Définir la frame par défaut (idle = première frame du spritesheet)
this.setFrame(0);
} }
/**
* Crée une texture temporaire pour le joueur
*/
private createPlaceholderTexture(scene: Phaser.Scene): void {
const graphics = scene.add.graphics();
graphics.fillStyle(0xFF0000, 1);
graphics.fillRect(0, 0, 50, 80);
graphics.generateTexture('player', 50, 80);
graphics.destroy();
this.setTexture('player');
}
/** /**
* Met à jour le mouvement du joueur * Met à jour le mouvement du joueur
@@ -117,15 +133,21 @@ export class Player extends Phaser.Physics.Arcade.Sprite {
this.jumpCount++; this.jumpCount++;
// Déclencher l'anim de saut immédiatement si disponible // Déclencher l'anim de saut immédiatement si disponible
if (this.animationsCreated && this.anims.get('player-jump')) { const jumpAnimKey = `${this.playerPrefix}-jump`;
this.anims.play('player-jump', true); const animExists = this.scene.anims.exists(jumpAnimKey);
console.log(`🦘 Tentative de saut #${this.jumpCount}: anim "${jumpAnimKey}" existe = ${animExists}, animationsCreated = ${this.animationsCreated}`);
if (this.animationsCreated && animExists) {
this.anims.play(jumpAnimKey, true);
console.log(`✅ Animation de saut jouée: ${jumpAnimKey}`);
} else {
console.warn(`⚠️ Animation de saut non disponible: ${jumpAnimKey}`);
} }
// Effet sonore de saut (volume global SFX depuis le registry) // Effet sonore de saut (volume global SFX depuis le registry)
const sfxVolume = (this.scene.registry.get('sfxVolume') as number | undefined) ?? 1; const sfxVolume = (this.scene.registry.get('sfxVolume') as number | undefined) ?? 1;
this.scene.sound.play('sfx_jump', { volume: 0.5 * sfxVolume }); this.scene.sound.play('sfx_jump', { volume: 0.5 * sfxVolume });
// TODO: Jouer son de saut (différent pour double saut)
console.log(`Saut ${this.jumpCount}/${PLAYER_MAX_JUMPS}`); console.log(`Saut ${this.jumpCount}/${PLAYER_MAX_JUMPS}`);
} }
} }
@@ -141,32 +163,46 @@ export class Player extends Phaser.Physics.Arcade.Sprite {
this.jumpCount = 0; this.jumpCount = 0;
} }
// TODO: Jouer les animations appropriées
if (!this.animationsCreated) return; if (!this.animationsCreated) return;
const isMoving = Math.abs(this.velocityX) > 10; const isMoving = Math.abs(this.velocityX) > 10;
const bodyState = this.body as Phaser.Physics.Arcade.Body; const bodyState = this.body as Phaser.Physics.Arcade.Body;
const isAir = !bodyState.touching.down; const isAir = !bodyState.touching.down;
// Utiliser les clés d'animation spécifiques au joueur
const walkAnimKey = `${this.playerPrefix}-walk`;
const idleAnimKey = `${this.playerPrefix}-idle`;
const jumpAnimKey = `${this.playerPrefix}-jump`;
// Transition sol -> air : jouer l'anim jump une fois // Transition sol -> air : jouer l'anim jump une fois
if (isAir && !this.wasAir) { if (isAir && !this.wasAir) {
const jumpAnim = this.anims.get('player-jump'); if (this.scene.anims.exists(jumpAnimKey)) {
if (jumpAnim) { this.anims.play(jumpAnimKey, true);
this.anims.play('player-jump', true);
} }
} }
// Sol : choisir walk ou idle // Sol : choisir walk ou idle
if (!isAir) { if (!isAir) {
if (isMoving) { if (isMoving) {
this.anims.play('player-walk', true); if (this.scene.anims.exists(walkAnimKey)) {
this.anims.play(walkAnimKey, true);
}
} else { } else {
this.anims.play('player-idle', true); // Jouer idle uniquement si on n'est pas déjà en train de le jouer
if (this.anims.currentAnim?.key !== idleAnimKey) {
if (this.scene.anims.exists(idleAnimKey)) {
this.anims.play(idleAnimKey, true);
} else {
// Fallback : afficher directement la frame 0 du spritesheet
this.anims.stop();
this.setFrame(0);
}
}
} }
} else { } else {
// En l'air : si pas d'anim jump, forcer dernière frame jump // En l'air : si pas d'anim jump, forcer dernière frame jump
if (this.anims.currentAnim?.key !== 'player-jump' && this.anims.get('player-jump')) { if (this.anims.currentAnim?.key !== jumpAnimKey && this.scene.anims.exists(jumpAnimKey)) {
this.anims.play('player-jump', true); this.anims.play(jumpAnimKey, true);
} }
} }
@@ -232,45 +268,97 @@ export class Player extends Phaser.Physics.Arcade.Sprite {
} }
/** /**
* Création des animations (walk + idle) * Création des animations (walk + idle + jump) selon le joueur sélectionné
*/ */
private ensureAnimations(): void { private ensureAnimations(): void {
if (this.animationsCreated) return; if (this.animationsCreated) return;
const scene = this.scene; const scene = this.scene;
const walkFrames = ['player_walk_1', 'player_walk_2', 'player_walk_3', 'player_walk_4'] const selectedPlayer = scene.registry.get('selectedPlayer') as string | undefined;
// Utiliser le préfixe du joueur (player_user1 ou player_user2)
const walkFrameKeys = [
`${this.playerPrefix}_walk_1`,
`${this.playerPrefix}_walk_2`,
`${this.playerPrefix}_walk_3`,
`${this.playerPrefix}_walk_4`
];
const jumpFrameKeys = [
`${this.playerPrefix}_jump_1`,
`${this.playerPrefix}_jump_2`,
`${this.playerPrefix}_jump_3`,
`${this.playerPrefix}_jump_4`,
`${this.playerPrefix}_jump_5`
];
// Debug: vérifier quelles textures existent
console.log(`🔍 Vérification des textures pour ${this.playerPrefix}:`);
walkFrameKeys.forEach(key => {
const exists = scene.textures.exists(key);
console.log(` Walk frame "${key}": ${exists ? '✅' : '❌'}`);
});
jumpFrameKeys.forEach(key => {
const exists = scene.textures.exists(key);
console.log(` Jump frame "${key}": ${exists ? '✅' : '❌'}`);
});
const walkFrames = walkFrameKeys
.filter((key) => scene.textures.exists(key)) .filter((key) => scene.textures.exists(key))
.map((key) => ({ key })); .map((key) => ({ key }));
const jumpFrames = ['player_jump_1', 'player_jump_2', 'player_jump_3', 'player_jump_4', 'player_jump_5']
const jumpFrames = jumpFrameKeys
.filter((key) => scene.textures.exists(key)) .filter((key) => scene.textures.exists(key))
.map((key) => ({ key })); .map((key) => ({ key }));
// Créer des clés d'animation uniques par joueur
const walkAnimKey = `${this.playerPrefix}-walk`;
const idleAnimKey = `${this.playerPrefix}-idle`;
const jumpAnimKey = `${this.playerPrefix}-jump`;
if (walkFrames.length >= 2) { if (walkFrames.length >= 2) {
scene.anims.create({ // Vérifier si l'animation existe déjà avant de la créer
key: 'player-walk', if (!scene.anims.exists(walkAnimKey)) {
frames: walkFrames, scene.anims.create({
frameRate: 10, key: walkAnimKey,
repeat: -1, frames: walkFrames,
}); frameRate: 10,
repeat: -1,
});
}
}
// Animation idle : utilise la frame 0 du spritesheet (pose debout)
const spritesheetKey = selectedPlayer === 'user2' ? 'player_user2' : 'player_user1';
if (!scene.anims.exists(idleAnimKey)) {
scene.anims.create({ scene.anims.create({
key: 'player-idle', key: idleAnimKey,
frames: [{ key: walkFrames[0].key }], frames: [{ key: spritesheetKey, frame: 0 }],
frameRate: 1, frameRate: 1,
repeat: -1, repeat: -1,
}); });
} }
if (jumpFrames.length >= 2) { if (jumpFrames.length >= 2) {
scene.anims.create({ if (!scene.anims.exists(jumpAnimKey)) {
key: 'player-jump', scene.anims.create({
frames: jumpFrames, key: jumpAnimKey,
frameRate: 12, frames: jumpFrames,
repeat: 0, frameRate: 12,
}); repeat: 0,
});
console.log(`✅ Animation de saut créée: ${jumpAnimKey} avec ${jumpFrames.length} frames`);
}
} else {
console.warn(`⚠️ Pas assez de frames pour l'animation de saut (${jumpFrames.length}/2 requis)`);
} }
this.animationsCreated = walkFrames.length >= 2 || jumpFrames.length >= 2; this.animationsCreated = walkFrames.length >= 2 || jumpFrames.length >= 2;
console.log(`🎬 Animations ${this.playerPrefix}:`, {
walk: walkFrames.length,
jump: jumpFrames.length,
created: this.animationsCreated
});
} }
/** /**

View File

@@ -9,18 +9,20 @@ export class SuperTreasure extends Phaser.Physics.Arcade.Sprite {
private pulseTimer: Phaser.Time.TimerEvent; private pulseTimer: Phaser.Time.TimerEvent;
constructor(scene: Phaser.Scene, x: number, y: number) { constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, 'supertreasure'); super(scene, x, y, 'star_sprite');
scene.add.existing(this); scene.add.existing(this);
scene.physics.add.existing(this); scene.physics.add.existing(this);
// Créer texture temporaire si elle n'existe pas // Utiliser le sprite star_40.png (40x40px) au lieu de créer une texture
if (!scene.textures.exists('supertreasure')) { // Si le sprite n'existe pas, créer une texture temporaire en fallback
if (scene.textures.exists('star_sprite')) {
this.setTexture('star_sprite');
} else if (!scene.textures.exists('supertreasure')) {
this.createPlaceholderTexture(scene); this.createPlaceholderTexture(scene);
this.setTexture('supertreasure');
} }
this.setTexture('supertreasure');
// Taille plus grande que les cadeaux normaux // Taille plus grande que les cadeaux normaux
this.setScale(1.5); this.setScale(1.5);

View File

@@ -8,6 +8,7 @@ import Phaser from 'phaser';
export class TreasureChest extends Phaser.Physics.Arcade.Sprite { export class TreasureChest extends Phaser.Physics.Arcade.Sprite {
private isOpen: boolean = false; private isOpen: boolean = false;
private requiredGifts: number; private requiredGifts: number;
private requirementText?: Phaser.GameObjects.Text;
constructor(scene: Phaser.Scene, x: number, y: number, requiredGifts: number = 15) { constructor(scene: Phaser.Scene, x: number, y: number, requiredGifts: number = 15) {
super(scene, x, y, 'chest'); super(scene, x, y, 'chest');
@@ -108,10 +109,10 @@ export class TreasureChest extends Phaser.Physics.Arcade.Sprite {
} }
/** /**
* Crée le texte qui indique le nombre de cadeaux requis * Crée le texte qui indique le nombre de cadeaux requis (caché par défaut)
*/ */
private createRequirementText(scene: Phaser.Scene, x: number, y: number): void { private createRequirementText(scene: Phaser.Scene, x: number, y: number): void {
const text = scene.add.text( this.requirementText = scene.add.text(
x, x,
y - 80, y - 80,
`🎁 ${this.requiredGifts} cadeaux requis`, `🎁 ${this.requiredGifts} cadeaux requis`,
@@ -123,12 +124,13 @@ export class TreasureChest extends Phaser.Physics.Arcade.Sprite {
fontStyle: 'bold', fontStyle: 'bold',
} }
); );
text.setOrigin(0.5); this.requirementText.setOrigin(0.5);
text.setDepth(this.depth + 1); this.requirementText.setDepth(this.depth + 1);
this.requirementText.setVisible(false); // Caché par défaut
// Animation pulse // Animation pulse
scene.tweens.add({ scene.tweens.add({
targets: text, targets: this.requirementText,
scaleX: 1.1, scaleX: 1.1,
scaleY: 1.1, scaleY: 1.1,
duration: 800, duration: 800,
@@ -137,6 +139,20 @@ export class TreasureChest extends Phaser.Physics.Arcade.Sprite {
}); });
} }
/**
* Affiche le texte des cadeaux requis
*/
public showRequirementText(): void {
this.requirementText?.setVisible(true);
}
/**
* Cache le texte des cadeaux requis
*/
public hideRequirementText(): void {
this.requirementText?.setVisible(false);
}
/** /**
* Vérifie si le joueur peut ouvrir le coffre * Vérifie si le joueur peut ouvrir le coffre
*/ */
@@ -145,10 +161,10 @@ export class TreasureChest extends Phaser.Physics.Arcade.Sprite {
} }
/** /**
* Ouvre le coffre et donne le mega bonus * Ouvre le coffre et donne la clé
*/ */
public open(scene: Phaser.Scene): number { public open(scene: Phaser.Scene): { bonus: number; hasKey: boolean } {
if (this.isOpen) return 0; if (this.isOpen) return { bonus: 0, hasKey: false };
this.isOpen = true; this.isOpen = true;
this.setTexture('chest-open'); this.setTexture('chest-open');
@@ -159,13 +175,13 @@ export class TreasureChest extends Phaser.Physics.Arcade.Sprite {
// Particules dorées qui explosent // Particules dorées qui explosent
this.createExplosionParticles(scene); this.createExplosionParticles(scene);
// Message épique // Message épique (sans la clé - elle sera affichée séparément)
const megaBonusText = scene.add.text( const megaBonusText = scene.add.text(
scene.cameras.main.scrollX + scene.cameras.main.width / 2, scene.cameras.main.scrollX + scene.cameras.main.width / 2,
scene.cameras.main.height / 2 - 100, scene.cameras.main.height / 2 - 100,
'🏆 COFFRE OUVERT ! 🏆\n★★ MEGA BONUS +1000 ★★', '🏆 COFFRE OUVERT ! 🏆\n★★ BONUS +1000 ★★',
{ {
fontSize: '56px', fontSize: '48px',
color: '#FFD700', color: '#FFD700',
stroke: '#FF4500', stroke: '#FF4500',
strokeThickness: 8, strokeThickness: 8,
@@ -190,9 +206,9 @@ export class TreasureChest extends Phaser.Physics.Arcade.Sprite {
}, },
}); });
console.log('🏆 COFFRE AU TRÉSOR OUVERT ! MEGA BONUS +1000 !'); console.log('🏆 COFFRE AU TRÉSOR OUVERT ! BONUS +1000 + CLÉ !');
return 1000; // Mega bonus de points return { bonus: 1000, hasKey: true }; // Mega bonus de points + clé
} }
/** /**

View File

@@ -1,9 +1,11 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from './utils/constants'; import { GAME_WIDTH, GAME_HEIGHT } from './utils/constants';
import { BootScene } from './scenes/BootScene'; import { BootScene } from './scenes/BootScene';
import { PlayerSelectScene } from './scenes/PlayerSelectScene';
import { MenuScene } from './scenes/MenuScene'; import { MenuScene } from './scenes/MenuScene';
import { GameScene } from './scenes/GameScene'; import { GameScene } from './scenes/GameScene';
import { IntroScene } from './scenes/IntroScene'; import { IntroScene } from './scenes/IntroScene';
import { IntroVideoScene } from './scenes/IntroVideoScene';
import { EndScene } from './scenes/EndScene'; import { EndScene } from './scenes/EndScene';
// Configuration Phaser // Configuration Phaser
@@ -23,7 +25,15 @@ const config: Phaser.Types.Core.GameConfig = {
debug: false, // Mettre à true pour voir les hitboxes debug: false, // Mettre à true pour voir les hitboxes
}, },
}, },
scene: [BootScene, IntroScene, MenuScene, GameScene, EndScene], scene: [
BootScene,
PlayerSelectScene,
IntroScene,
IntroVideoScene,
MenuScene,
GameScene,
EndScene,
],
backgroundColor: '#87CEEB', backgroundColor: '#87CEEB',
render: { render: {
pixelArt: false, pixelArt: false,

View File

@@ -6,12 +6,13 @@ const game = new Phaser.Game(config);
// Gestion du fullscreen au clic (optionnel) // Gestion du fullscreen au clic (optionnel)
window.addEventListener('load', () => { window.addEventListener('load', () => {
// Enregistrer le service worker pour PWA // TODO: Enregistrer le service worker pour PWA (désactivé temporairement)
if ('serviceWorker' in navigator) { // Il faut d'abord créer le fichier public/service-worker.js
navigator.serviceWorker.register('/service-worker.js').catch((error) => { // if ('serviceWorker' in navigator) {
console.log('Service Worker registration failed:', error); // navigator.serviceWorker.register('/service-worker.js').catch((error) => {
}); // console.log('Service Worker registration failed:', error);
} // });
// }
// Bloquer le zoom pinch sur mobile // Bloquer le zoom pinch sur mobile
document.addEventListener('gesturestart', (e) => e.preventDefault()); document.addEventListener('gesturestart', (e) => e.preventDefault());

View File

@@ -42,21 +42,36 @@ export class BootScene extends Phaser.Scene {
loadingText.destroy(); loadingText.destroy();
}); });
// Sprites du joueur (80x169, 1 frame pour l'instant) // Charger les sprites des deux joueurs
this.load.spritesheet('player', 'assets/sprites/player_spritesheet.png', { // User1 (Baptiste) - dimensions réelles du spritesheet 93x224px
frameWidth: 80, this.load.spritesheet('player_user1', 'assets/sprites/user1/player_spritesheet.png', {
frameHeight: 169, frameWidth: 93,
frameHeight: 224,
}); });
// Frames de marche (sprite individuel) this.load.image('player_user1_walk_1', 'assets/sprites/user1/walk_1.png');
this.load.image('player_walk_1', 'assets/sprites/walk_1.png'); this.load.image('player_user1_walk_2', 'assets/sprites/user1/walk_2.png');
this.load.image('player_walk_2', 'assets/sprites/walk_2.png'); this.load.image('player_user1_walk_3', 'assets/sprites/user1/walk_3.png');
this.load.image('player_walk_3', 'assets/sprites/walk_3.png'); this.load.image('player_user1_walk_4', 'assets/sprites/user1/walk_4.png');
this.load.image('player_walk_4', 'assets/sprites/walk_4.png'); this.load.image('player_user1_jump_1', 'assets/sprites/user1/jump_1.png');
this.load.image('player_jump_1', 'assets/sprites/jump_1.png'); this.load.image('player_user1_jump_2', 'assets/sprites/user1/jump_2.png');
this.load.image('player_jump_2', 'assets/sprites/jump_2.png'); this.load.image('player_user1_jump_3', 'assets/sprites/user1/jump_3.png');
this.load.image('player_jump_3', 'assets/sprites/jump_3.png'); this.load.image('player_user1_jump_4', 'assets/sprites/user1/jump_4.png');
this.load.image('player_jump_4', 'assets/sprites/jump_4.png'); this.load.image('player_user1_jump_5', 'assets/sprites/user1/jump_5.png');
this.load.image('player_jump_5', 'assets/sprites/jump_5.png');
// User2 (Julien) - dimensions réelles du spritesheet
this.load.spritesheet('player_user2', 'assets/sprites/user2/player_spritesheet.png', {
frameWidth: 93,
frameHeight: 224,
});
this.load.image('player_user2_walk_1', 'assets/sprites/user2/walk_1.png');
this.load.image('player_user2_walk_2', 'assets/sprites/user2/walk_2.png');
this.load.image('player_user2_walk_3', 'assets/sprites/user2/walk_3.png');
this.load.image('player_user2_walk_4', 'assets/sprites/user2/walk_4.png');
this.load.image('player_user2_jump_1', 'assets/sprites/user2/jump_1.png');
this.load.image('player_user2_jump_2', 'assets/sprites/user2/jump_2.png');
this.load.image('player_user2_jump_3', 'assets/sprites/user2/jump_3.png');
this.load.image('player_user2_jump_4', 'assets/sprites/user2/jump_4.png');
this.load.image('player_user2_jump_5', 'assets/sprites/user2/jump_5.png');
// Musique de fond // Musique de fond
this.load.audio('bgm', 'assets/audio/01. Ground Theme.mp3'); this.load.audio('bgm', 'assets/audio/01. Ground Theme.mp3');
@@ -72,24 +87,107 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_hit', ['assets/audio/champignon.mp3', 'assets/audio/champignon.aiff']); this.load.audio('sfx_hit', ['assets/audio/champignon.mp3', 'assets/audio/champignon.aiff']);
this.load.audio('sfx_super', 'assets/audio/super_tresor.mp3'); this.load.audio('sfx_super', 'assets/audio/super_tresor.mp3');
this.load.audio('sfx_saute_champi', 'assets/audio/saute_champi.mp3'); this.load.audio('sfx_saute_champi', 'assets/audio/saute_champi.mp3');
this.load.audio('sfx_chien', 'assets/audio/chien.mp3');
// Sprites obstacles // Sprites obstacles
this.load.image('obstacle_mushroom', 'assets/sprites/champignon.png'); this.load.image('obstacle_mushroom', 'assets/sprites/champignon.png');
// Vidéo d'intro (mp4 uniquement) // Sprites plateformes (blocs de différentes tailles)
this.load.image('platform_40', 'assets/sprites/decor/b_40.png');
this.load.image('platform_73', 'assets/sprites/decor/b_73.png');
this.load.image('platform_130', 'assets/sprites/decor/b_130.png');
this.load.image('platform_140', 'assets/sprites/decor/b_140.png');
this.load.image('platform_179', 'assets/sprites/decor/b_179.png');
// Sol (vert)
this.load.image('ground_tile', 'assets/sprites/decor/sol_150.png');
// Sprites objets de jeu
this.load.image('gift_sprite', 'assets/sprites/decor/gift_40.png');
this.load.image('star_sprite', 'assets/sprites/decor/star_40.png');
// Selection joueur (portrait)
this.load.image('user1', 'assets/images/user1.jpg');
this.load.image('user2', 'assets/images/user2.jpg');
// Images finales (affichées après la vidéo de fin)
this.load.image('finale_user1', 'assets/sprites/user1/image finale1.jpeg');
this.load.image('finale_user2', 'assets/sprites/user2/image finale2.jpeg');
// Vidéos d'intro (mp4 uniquement) - une par joueur
// Le 3e paramètre 'noAudio' est à false pour garder l'audio si présent // Le 3e paramètre 'noAudio' est à false pour garder l'audio si présent
// Ajout d'un timestamp pour forcer le rechargement (éviter le cache) // Ajout d'un timestamp pour forcer le rechargement (éviter le cache)
const timestamp = Date.now(); const timestamp = Date.now();
this.load.video('intro', `assets/video/intro.mp4?v=${timestamp}`, false); this.load.video('intro_user1', `assets/video/intro_user1.mp4?v=${timestamp}`, false);
this.load.video('intro_user2', `assets/video/intro_user2.mp4?v=${timestamp}`, false);
// Vidéo de fin (quand le joueur gagne) // Vidéo de fin (quand le joueur gagne)
this.load.video('end', `assets/video/end_J.mp4?v=${timestamp}`, false); this.load.video('end', `assets/video/zoe cadeaux1.mp4?v=${timestamp}`, false);
// Sprites du chien
this.load.image('dog-idle-1', 'assets/sprites/dog/idle_01.png');
this.load.image('dog-idle-2', 'assets/sprites/dog/idle_02.png');
this.load.image('dog-idle-3', 'assets/sprites/dog/idle_03.png');
this.load.image('dog-idle-4', 'assets/sprites/dog/idle_04.png');
this.load.image('dog-walkback-1', 'assets/sprites/dog/walkback_01.png');
this.load.image('dog-walkback-2', 'assets/sprites/dog/walkback_02.png');
this.load.image('dog-walkback-3', 'assets/sprites/dog/walkback_03.png');
this.load.image('dog-walkback-4', 'assets/sprites/dog/walkback_04.png');
this.load.image('dog-turn-1', 'assets/sprites/dog/turn_01.png');
this.load.image('dog-turn-2', 'assets/sprites/dog/turn_02.png');
this.load.image('dog-turn-3', 'assets/sprites/dog/turn_03.png');
// TODO: Charger d'autres sprites, backgrounds, sons, etc. // TODO: Charger d'autres sprites, backgrounds, sons, etc.
} }
create(): void { create(): void {
// Passer par l'intro vidéo puis le menu // Créer les animations du chien
this.scene.start('IntroScene'); this.createDogAnimations();
// Demarrer par la selection joueur (portrait sur mobile)
this.scene.start('PlayerSelectScene');
}
/**
* Crée les animations du chien
*/
private createDogAnimations(): void {
// Animation IDLE (queue qui remue)
this.anims.create({
key: 'dog-idle',
frames: [
{ key: 'dog-idle-1' },
{ key: 'dog-idle-2' },
{ key: 'dog-idle-3' },
{ key: 'dog-idle-4' }
],
frameRate: 6,
repeat: -1
});
// Animation WALK BACK (marche vers la gauche)
this.anims.create({
key: 'dog-walkback',
frames: [
{ key: 'dog-walkback-1' },
{ key: 'dog-walkback-2' },
{ key: 'dog-walkback-3' },
{ key: 'dog-walkback-4' }
],
frameRate: 8,
repeat: -1
});
// Animation TURN (demi-tour)
this.anims.create({
key: 'dog-turn',
frames: [
{ key: 'dog-turn-1' },
{ key: 'dog-turn-2' },
{ key: 'dog-turn-3' }
],
frameRate: 10,
repeat: 0
});
} }
} }

View File

@@ -18,8 +18,51 @@ export class EndScene extends Phaser.Scene {
console.log('[EndScene] Création de la scène de fin'); console.log('[EndScene] Création de la scène de fin');
console.log('[EndScene] Dimensions:', width, 'x', height); console.log('[EndScene] Dimensions:', width, 'x', height);
// Lancer directement la vidéo de fin // Afficher le bouton de lecture au lieu de lancer automatiquement
this.playEndVideo(); this.showPlayButton();
}
/**
* Affiche le bouton de lecture
*/
private showPlayButton(): void {
const { width, height } = this.cameras.main;
// Message "Appuyez sur l'écran pour visualiser la vidéo"
const message = this.add.text(
width / 2,
height / 2 - 80,
'Appuyez sur l\'écran\npour visualiser la vidéo',
{
fontSize: '32px',
color: '#ffffff',
align: 'center',
fontFamily: 'Arial',
stroke: '#000000',
strokeThickness: 4,
}
);
message.setOrigin(0.5);
// Bouton play (cercle avec triangle)
const playButton = this.add.container(width / 2, height / 2 + 60);
const circle = this.add.circle(0, 0, 60, 0x4CAF50, 1);
circle.setStrokeStyle(4, 0xffffff);
const triangle = this.add.triangle(5, 0, -15, -20, -15, 20, 20, 0, 0xffffff);
playButton.add([circle, triangle]);
circle.setInteractive({ useHandCursor: true });
// Clic sur le bouton ou n'importe où sur l'écran
const startVideo = () => {
message.destroy();
playButton.destroy();
this.playEndVideo();
};
circle.on('pointerdown', startVideo);
this.input.once('pointerdown', startVideo);
} }
/** /**
@@ -58,8 +101,16 @@ export class EndScene extends Phaser.Scene {
this.video.setMute(false); this.video.setMute(false);
this.video.setLoop(false); this.video.setLoop(false);
// IMPORTANT: Forcer explicitement loop=false sur l'élément HTML5 natif
// pour éviter que la vidéo se lise en boucle
if (this.video.video) {
this.video.video.loop = false;
console.log('[EndScene] Attribut loop forcé à false sur l\'élément vidéo natif');
}
console.log('[EndScene] Démarrage de la lecture'); console.log('[EndScene] Démarrage de la lecture');
const started = this.video.play(true); // Autoplay // IMPORTANT: Utiliser play(true) pour l'autoplay après interaction utilisateur (requis pour iOS)
const started = this.video.play(true);
console.log('[EndScene] Lecture démarrée?', started); console.log('[EndScene] Lecture démarrée?', started);
if (!started) { if (!started) {
@@ -85,22 +136,40 @@ export class EndScene extends Phaser.Scene {
this.gotoMenu(); this.gotoMenu();
}); });
// Sécurité : timer basé sur la durée de la vidéo + 2 secondes // IMPORTANT: Écouter aussi l'événement natif 'ended' de l'élément HTML5
// C'est la méthode la plus fiable pour détecter la fin d'une vidéo
if (this.video.video) {
this.video.video.addEventListener('ended', () => {
console.log('[EndScene] Vidéo terminée (événement HTML5 ended) → passage au menu');
this.gotoMenu();
}, { once: true });
}
// Sécurité : timer basé sur la durée de la vidéo + 1 seconde
// Si la vidéo n'est pas finie après sa durée, forcer le passage au menu // Si la vidéo n'est pas finie après sa durée, forcer le passage au menu
this.video.on('metadata', () => { this.video.on('metadata', () => {
if (!this.video || !this.video.video) return; if (!this.video || !this.video.video) return;
const duration = this.video.getDuration(); const duration = this.video.getDuration();
console.log('[EndScene] Durée de la vidéo:', duration, 'secondes'); console.log('[EndScene] Durée de la vidéo:', duration, 'secondes');
// Timer de sécurité : durée vidéo + 2 secondes // Timer de sécurité : durée vidéo + 1 seconde
this.time.delayedCall((duration + 2) * 1000, () => { this.time.delayedCall((duration + 1) * 1000, () => {
if (!this.hasFinished) { if (!this.hasFinished) {
console.warn('[EndScene] Timer de sécurité déclenché → passage au menu'); console.warn('[EndScene] Timer de sécurité (metadata) déclenché → passage au menu');
this.gotoMenu(); this.gotoMenu();
} }
}); });
}); });
// Timer de sécurité fixe basé sur la durée connue (36 secondes exactement)
// Au cas où les métadonnées ne se chargent pas correctement
this.time.delayedCall(36000, () => {
if (!this.hasFinished) {
console.warn('[EndScene] Timer de sécurité fixe (36s) déclenché → passage au menu');
this.gotoMenu();
}
});
// Ajuster si resize // Ajuster si resize
this.scale.on('resize', (gameSize: Phaser.Structs.Size) => { this.scale.on('resize', (gameSize: Phaser.Structs.Size) => {
if (this.video && this.video.isPlaying()) { if (this.video && this.video.isPlaying()) {
@@ -150,13 +219,155 @@ export class EndScene extends Phaser.Scene {
} }
/** /**
* Passe au menu * Affiche l'image finale après la vidéo
*/ */
private gotoMenu(): void { private gotoMenu(): void {
if (this.hasFinished) return; if (this.hasFinished) return;
this.hasFinished = true; this.hasFinished = true;
// Détruire la vidéo
this.video?.stop(); this.video?.stop();
this.video?.destroy(); this.video?.destroy();
this.scene.start('MenuScene');
// Afficher l'image finale
this.showFinalImage();
}
/**
* Affiche l'image finale du joueur puis le message de réussite
*/
private showFinalImage(): void {
// Récupérer le joueur sélectionné
const selectedPlayer = this.registry.get('selectedPlayer') as string | undefined;
const imageKey = selectedPlayer === 'user2' ? 'finale_user2' : 'finale_user1';
console.log('[EndScene] Affichage image finale:', imageKey);
// Afficher l'image en grand (centrée et mise à l'échelle)
const finalImage = this.add.image(
this.cameras.main.width / 2,
this.cameras.main.height / 2,
imageKey
);
// Mettre à l'échelle pour s'adapter à l'écran sans dépasser (mode "contain")
// Limiter à 85% de la hauteur/largeur pour laisser de l'espace pour le message
const maxWidth = this.cameras.main.width * 0.85;
const maxHeight = this.cameras.main.height * 0.85;
const scaleX = maxWidth / finalImage.width;
const scaleY = maxHeight / finalImage.height;
const scale = Math.min(scaleX, scaleY); // Min pour contenir sans déborder
finalImage.setScale(scale);
finalImage.setDepth(0);
// Après 2 secondes, afficher le message et le bouton
this.time.delayedCall(2000, () => {
this.showSuccessMessage();
});
}
/**
* Affiche le message "Niveau 1 réussi!" et le bouton
*/
private showSuccessMessage(): void {
// Message "Niveau 1 réussi!"
const successText = this.add.text(
this.cameras.main.width / 2,
this.cameras.main.height / 2 - 100,
'Niveau 1 réussi!',
{
fontSize: '64px',
color: '#FFD700',
stroke: '#000000',
strokeThickness: 8,
fontStyle: 'bold',
}
);
successText.setOrigin(0.5);
successText.setDepth(1000);
successText.setAlpha(0);
// Animation d'apparition du texte
this.tweens.add({
targets: successText,
alpha: 1,
scale: { from: 0.5, to: 1.2 },
duration: 800,
ease: 'Back.easeOut',
});
// Bouton "Passer au niveau 2"
const buttonY = this.cameras.main.height / 2 + 100;
// Fond du bouton
const buttonBg = this.add.rectangle(
this.cameras.main.width / 2,
buttonY,
400,
80,
0x4CAF50
);
buttonBg.setOrigin(0.5);
buttonBg.setDepth(999);
buttonBg.setInteractive({ useHandCursor: true });
buttonBg.setAlpha(0);
// Texte du bouton
const buttonText = this.add.text(
this.cameras.main.width / 2,
buttonY,
'Passer au niveau 2',
{
fontSize: '32px',
color: '#FFFFFF',
fontStyle: 'bold',
}
);
buttonText.setOrigin(0.5);
buttonText.setDepth(1000);
buttonText.setAlpha(0);
// Animation d'apparition du bouton (un peu après le texte)
this.tweens.add({
targets: [buttonBg, buttonText],
alpha: 1,
duration: 600,
delay: 400,
ease: 'Power2',
});
// Effet hover sur le bouton
buttonBg.on('pointerover', () => {
buttonBg.setFillStyle(0x66BB6A);
this.tweens.add({
targets: buttonBg,
scaleX: 1.05,
scaleY: 1.05,
duration: 200,
});
});
buttonBg.on('pointerout', () => {
buttonBg.setFillStyle(0x4CAF50);
this.tweens.add({
targets: buttonBg,
scaleX: 1,
scaleY: 1,
duration: 200,
});
});
// Clic sur le bouton → Fin du jeu (retour au menu)
buttonBg.on('pointerdown', () => {
console.log('[EndScene] Bouton "Passer au niveau 2" cliqué → retour au menu');
// Flash blanc
this.cameras.main.flash(300, 255, 255, 255, true);
// Retour au menu
this.time.delayedCall(300, () => {
this.scene.start('MenuScene');
});
});
} }
} }

View File

@@ -1,11 +1,12 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { LEVEL_DURATION, PLAYER_STARTING_LIVES, CHEST_REQUIRED_GIFTS } from '../utils/constants'; import { LEVEL_DURATION, PLAYER_STARTING_LIVES, CHEST_REQUIRED_GIFTS, MIN_MUSHROOMS } from '../utils/constants';
import { Player } from '../entities/Player'; import { Player } from '../entities/Player';
import { GyroControl } from '../controls/GyroControl'; import { GyroControl } from '../controls/GyroControl';
import { JumpButton } from '../controls/JumpButton'; import { JumpButton } from '../controls/JumpButton';
import { DirectionalButtons } from '../controls/DirectionalButtons'; import { DirectionalButtons } from '../controls/DirectionalButtons';
import { SuperTreasure } from '../entities/SuperTreasure'; import { SuperTreasure } from '../entities/SuperTreasure';
import { TreasureChest } from '../entities/TreasureChest'; import { TreasureChest } from '../entities/TreasureChest';
import { Cage } from '../entities/Cage';
/** /**
* Scène principale du jeu * Scène principale du jeu
@@ -24,6 +25,8 @@ export class GameScene extends Phaser.Scene {
private gifts?: Phaser.Physics.Arcade.Group; private gifts?: Phaser.Physics.Arcade.Group;
private superTreasures?: Phaser.Physics.Arcade.Group; private superTreasures?: Phaser.Physics.Arcade.Group;
private treasureChest?: TreasureChest; private treasureChest?: TreasureChest;
private cage?: Cage;
private hasKey: boolean = false;
private bgMusic?: Phaser.Sound.BaseSound; private bgMusic?: Phaser.Sound.BaseSound;
private platformRects: { x: number; y: number; w: number; h: number }[] = []; private platformRects: { x: number; y: number; w: number; h: number }[] = [];
@@ -38,6 +41,11 @@ export class GameScene extends Phaser.Scene {
private giftsCollectedText?: Phaser.GameObjects.Text; private giftsCollectedText?: Phaser.GameObjects.Text;
private volumeText?: Phaser.GameObjects.Text; private volumeText?: Phaser.GameObjects.Text;
private sfxVolumeText?: Phaser.GameObjects.Text; private sfxVolumeText?: Phaser.GameObjects.Text;
private debugText?: Phaser.GameObjects.Text;
private lastDebugUpdate: number = 0;
private gyroDebugButton?: Phaser.GameObjects.Text;
private gyroDomButton?: HTMLButtonElement;
private recalibrateDomButton?: HTMLButtonElement;
// Game state // Game state
private score: number = 0; private score: number = 0;
@@ -73,8 +81,8 @@ export class GameScene extends Phaser.Scene {
this.isMobile = this.sys.game.device.os.android || this.sys.game.device.os.iOS; this.isMobile = this.sys.game.device.os.android || this.sys.game.device.os.iOS;
// Configurer les limites du monde physique (IMPORTANT pour permettre mouvement infini) // Configurer les limites du monde physique (IMPORTANT pour permettre mouvement infini)
// Étendre un peu plus pour inclure la plateforme finale et le coffre // Étendre encore plus pour inclure la cage du chien après le coffre
const levelWidth = Math.max(width * 7, 8000); const levelWidth = Math.max(width * 7, 10500); // Augmenté de 2500px
this.physics.world.setBounds(0, 0, levelWidth, height); this.physics.world.setBounds(0, 0, levelWidth, height);
// Créer le background qui défile // Créer le background qui défile
@@ -97,6 +105,9 @@ export class GameScene extends Phaser.Scene {
if (!playerBody || !platformBody) return true; if (!playerBody || !platformBody) return true;
const isGround = (platform as any).getData?.('isGround') === true;
if (isGround) return true;
// Autoriser la collision uniquement si le joueur vient du dessus // Autoriser la collision uniquement si le joueur vient du dessus
// (sa vitesse verticale est positive = tombe, et son bas est au-dessus du haut de la plateforme) // (sa vitesse verticale est positive = tombe, et son bas est au-dessus du haut de la plateforme)
return playerBody.velocity.y >= 0 && playerBody.bottom <= platformBody.top + 10; return playerBody.velocity.y >= 0 && playerBody.bottom <= platformBody.top + 10;
@@ -111,6 +122,8 @@ export class GameScene extends Phaser.Scene {
// Contrôles PC (clavier) // Contrôles PC (clavier)
this.cursors = this.input.keyboard?.createCursorKeys(); this.cursors = this.input.keyboard?.createCursorKeys();
console.log('[GameScene] 🎮 Cursors initialized:', this.cursors ? '✅' : '❌',
'Left:', !!this.cursors?.left, 'Right:', !!this.cursors?.right, 'Space:', !!this.cursors?.space);
// Contrôles Mobile (gyroscope + bouton tactile) // Contrôles Mobile (gyroscope + bouton tactile)
if (this.isMobile) { if (this.isMobile) {
@@ -119,6 +132,9 @@ export class GameScene extends Phaser.Scene {
// UI // UI
this.createUI(); this.createUI();
this.createDebugOverlay();
this.createGyroDomButton();
this.createRecalibrateDomButton();
// Effet neige // Effet neige
this.createSnow(); this.createSnow();
@@ -173,13 +189,16 @@ export class GameScene extends Phaser.Scene {
private createPlatforms(): void { private createPlatforms(): void {
this.platforms = this.physics.add.staticGroup(); this.platforms = this.physics.add.staticGroup();
const width = this.cameras.main.width;
const height = this.cameras.main.height; const height = this.cameras.main.height;
// Sol principal (très large pour le niveau 6x) // Sol principal (très large pour tout le niveau jusqu'à la cage + 400px)
const groundWidth = width * 6; // Utiliser TileSprite pour répéter le motif sur toute la longueur
const ground = this.add.rectangle(groundWidth / 2, height - 25, groundWidth, 50, 0x8B4513); const groundWidth = 10500; // Jusqu'à x=10500 (cage à 10100 + 400px)
// Sol avec sprite répété (sol_150.png)
const ground = this.add.tileSprite(groundWidth / 2, height - 25, groundWidth, 50, 'ground_tile');
this.physics.add.existing(ground, true); this.physics.add.existing(ground, true);
(ground as any).setData?.('isGround', true);
this.platforms.add(ground); this.platforms.add(ground);
this.platformRects.push({ x: groundWidth / 2, y: height - 25, w: groundWidth, h: 50 }); this.platformRects.push({ x: groundWidth / 2, y: height - 25, w: groundWidth, h: 50 });
@@ -219,13 +238,28 @@ export class GameScene extends Phaser.Scene {
{ x: 6800, y: height - 450, w: 130, h: 30 }, { x: 6800, y: height - 450, w: 130, h: 30 },
{ x: 7100, y: height - 350, w: 180, h: 30 }, { x: 7100, y: height - 350, w: 180, h: 30 },
// Zone 6 (finale) // Zone 6 (finale avant coffre)
{ x: 7400, y: height - 250, w: 200, h: 30 }, { x: 7400, y: height - 250, w: 200, h: 30 },
{ x: 7700, y: height - 180, w: 300, h: 30 }, // Grande plateforme finale { x: 7700, y: height - 180, w: 300, h: 30 }, // Grande plateforme pour coffre
// Zone 7 (après le coffre - vers la cage du chien) +2500px
{ x: 8000, y: height - 200, w: 180, h: 30 },
{ x: 8300, y: height - 320, w: 160, h: 30 },
{ x: 8600, y: height - 240, w: 200, h: 30 },
{ x: 8900, y: height - 380, w: 140, h: 30 },
{ x: 9200, y: height - 280, w: 180, h: 30 },
{ x: 9500, y: height - 400, w: 150, h: 30 },
{ x: 9800, y: height - 200, w: 220, h: 30 },
// Grande plateforme finale pour la cage - prolongée jusqu'à la fin (2600px de long)
{ x: 9900, y: height - 150, w: 2600, h: 30 }
]; ];
platformPositions.forEach((pos) => { platformPositions.forEach((pos) => {
const platform = this.add.rectangle(pos.x, pos.y, pos.w, pos.h, 0x6B8E23); // Choisir le meilleur sprite selon la largeur de la plateforme
const spriteKey = this.choosePlatformSprite(pos.w);
// Créer un TileSprite qui répète automatiquement le motif
const platform = this.add.tileSprite(pos.x, pos.y, pos.w, pos.h, spriteKey);
this.physics.add.existing(platform, true); this.physics.add.existing(platform, true);
this.platforms!.add(platform); this.platforms!.add(platform);
this.platformRects.push({ x: pos.x, y: pos.y, w: pos.w, h: pos.h }); this.platformRects.push({ x: pos.x, y: pos.y, w: pos.w, h: pos.h });
@@ -234,6 +268,47 @@ export class GameScene extends Phaser.Scene {
console.log(`${platformPositions.length} plateformes créées sur ${groundWidth}px`); console.log(`${platformPositions.length} plateformes créées sur ${groundWidth}px`);
} }
/**
* Choisit le meilleur sprite de plateforme selon la largeur
* Stratégie : utiliser le sprite le plus proche de la largeur demandée
*/
private choosePlatformSprite(width: number): string {
// Largeurs des sprites disponibles
const sprites = [
{ key: 'platform_40', width: 40 },
{ key: 'platform_73', width: 73 },
{ key: 'platform_130', width: 130 },
{ key: 'platform_140', width: 140 },
{ key: 'platform_179', width: 179 }
];
// Pour les très grandes plateformes (>200px), toujours utiliser le plus grand
if (width >= 200) {
return 'platform_179';
}
// Pour les petites/moyennes, choisir le sprite le plus proche
// qui divise bien la largeur totale (pour éviter les coupures bizarres)
let bestSprite = sprites[sprites.length - 1]; // Par défaut, le plus grand
let bestScore = Infinity;
for (const sprite of sprites) {
// Calculer combien de fois le sprite rentre dans la largeur
const repetitions = width / sprite.width;
const remainder = width % sprite.width;
// Score = reste (on préfère un reste petit) + pénalité si trop de répétitions
const score = remainder + Math.abs(repetitions - Math.round(repetitions)) * 10;
if (score < bestScore) {
bestScore = score;
bestSprite = sprite;
}
}
return bestSprite.key;
}
/** /**
* Crée les groupes d'obstacles, cadeaux et super trésors * Crée les groupes d'obstacles, cadeaux et super trésors
*/ */
@@ -253,6 +328,37 @@ export class GameScene extends Phaser.Scene {
*/ */
private spawnTestObjects(): void { private spawnTestObjects(): void {
const height = this.cameras.main.height; const height = this.cameras.main.height;
const minObjectSpacing = 120;
const reservedPositions: Array<{ x: number; y: number; radius: number }> = [];
const reserve = (x: number, y: number, radius: number): void => {
reservedPositions.push({ x, y, radius });
};
const isTooClose = (x: number, y: number): boolean =>
reservedPositions.some((pos) => {
const distance = Phaser.Math.Distance.Between(x, y, pos.x, pos.y);
return distance < minObjectSpacing + pos.radius;
});
let obstacleCount = 0;
const targetObstacleCount = MIN_MUSHROOMS;
const obstacleCandidates: Array<{ x: number; y: number }> = [];
const addCandidate = (x: number, y: number): void => {
obstacleCandidates.push({ x, y });
};
// SUPER TRESORS (rares et precieux - 1 par zone)
const superTreasurePositions = [
{ x: 1000, y: height - 350 }, // Zone 1 - en hauteur
{ x: 2500, y: height - 420 }, // Zone 2 - tres haut
{ x: 3900, y: height - 450 }, // Zone 3 - tres haut
{ x: 5400, y: height - 470 }, // Zone 4 - ultra haut
{ x: 6800, y: height - 500 }, // Zone 5 - ultra haut
{ x: 7300, y: height - 250 }, // Zone 6 - sur plateforme finale
];
// Coffre posé sur la plateforme à height - 180 (hauteur plateforme = 30px)
const chestPosition = { x: 7700, y: height - 180 - 50 }; // -50 pour être bien au-dessus de la plateforme
superTreasurePositions.forEach((pos) => reserve(pos.x, pos.y, 40));
reserve(chestPosition.x, chestPosition.y, 60);
// BEAUCOUP de cadeaux répartis partout (environ tous les 300-500px) // BEAUCOUP de cadeaux répartis partout (environ tous les 300-500px)
const giftPositions = [ const giftPositions = [
@@ -268,6 +374,8 @@ export class GameScene extends Phaser.Scene {
5400, 5700, 6000, 6300, 5400, 5700, 6000, 6300,
// Zone 6 // Zone 6
6600, 6900, 7200, 7500, 6600, 6900, 7200, 7500,
// Zone 7 (après le coffre - vers la cage)
8000, 8300, 8600, 8900, 9200, 9500, 9800, 10100,
]; ];
giftPositions.forEach((x) => { giftPositions.forEach((x) => {
@@ -275,9 +383,12 @@ export class GameScene extends Phaser.Scene {
const isHigh = Math.random() > 0.5; const isHigh = Math.random() > 0.5;
const y = isHigh ? height - 200 - Math.random() * 150 : height - 100; const y = isHigh ? height - 200 - Math.random() * 150 : height - 100;
const gift = this.add.circle(x, y, 20, 0xFFEB3B); // Utiliser le sprite de cadeau (gift_40.png = 40x40px)
const gift = this.add.image(x, y, 'gift_sprite');
gift.setScale(1); // Garder la taille native (40x40)
this.physics.add.existing(gift); this.physics.add.existing(gift);
this.gifts!.add(gift); this.gifts!.add(gift);
reserve(x, y, 25);
}); });
// BEAUCOUP d'obstacles répartis partout // BEAUCOUP d'obstacles répartis partout
@@ -294,6 +405,8 @@ export class GameScene extends Phaser.Scene {
5600, 5900, 6200, 6500, 5600, 5900, 6200, 6500,
// Zone 6 // Zone 6
6800, 7100, 7400, 6800, 7100, 7400,
// Zone 7 (après le coffre - vers la cage) - PAS de champignons près de la cage
8100, 8400, 8700, 9000,
]; ];
// Obstacles sur plateformes (x, y) // Obstacles sur plateformes (x, y)
@@ -314,48 +427,28 @@ export class GameScene extends Phaser.Scene {
const platformPlaced: Array<{ x: number; y: number }> = []; const platformPlaced: Array<{ x: number; y: number }> = [];
this.platformRects this.platformRects
.filter((rect) => rect.y < height - 80 && rect.w >= 120) // ignorer le sol et les très petites plateformes .filter((rect) => rect.y < height - 80 && rect.w >= 120) // ignorer le sol et les très petites plateformes
.forEach((rect, idx) => { .forEach((rect) => {
// Placer un champignon sur ~1 plateforme sur 2 pour ne pas surcharger
if (idx % 2 !== 0) return;
const x = rect.x + Phaser.Math.Between(Math.round(-rect.w / 4), Math.round(rect.w / 4)); const x = rect.x + Phaser.Math.Between(Math.round(-rect.w / 4), Math.round(rect.w / 4));
const y = rect.y - rect.h / 2; const y = rect.y - rect.h / 2;
platformPlaced.push({ x, y }); platformPlaced.push({ x, y });
}); });
obstaclePositions.filter((_, idx) => idx % 2 === 0).forEach((x) => { obstaclePositions.forEach((x) => addCandidate(x, height - 50));
const obstacle = this.physics.add.sprite(x, height - 50, 'obstacle_mushroom');
obstacle.setOrigin(0.5, 1); // ancré sur les pieds
obstacle.setImmovable(true);
obstacle.setPushable(false);
obstacle.setScale(0.9);
const body = obstacle.body as Phaser.Physics.Arcade.Body; obstaclePlatforms.forEach((pos) => {
body.setAllowGravity(false);
body.setSize(45, 81); // hitbox 10% plus petite avec le scale
body.setOffset((obstacle.displayWidth - 45) / 2, obstacle.displayHeight - 81);
this.obstacles!.add(obstacle);
});
obstaclePlatforms.filter((_, idx) => idx % 2 === 0).forEach((pos) => {
// Trouver la plateforme la plus proche à cet x
const target = this.platformRects.find((rect) => Math.abs(pos.x - rect.x) <= rect.w / 2); const target = this.platformRects.find((rect) => Math.abs(pos.x - rect.x) <= rect.w / 2);
const topY = target ? target.y - target.h / 2 : pos.y; const topY = target ? target.y - target.h / 2 : pos.y;
const obstacle = this.physics.add.sprite(pos.x, topY, 'obstacle_mushroom'); addCandidate(pos.x, topY);
obstacle.setOrigin(0.5, 1);
obstacle.setImmovable(true);
obstacle.setPushable(false);
obstacle.setScale(0.9);
const body = obstacle.body as Phaser.Physics.Arcade.Body;
body.setAllowGravity(false);
body.setSize(45, 81);
body.setOffset((obstacle.displayWidth - 45) / 2, obstacle.displayHeight - 81);
this.obstacles!.add(obstacle);
}); });
platformPlaced.filter((_, idx) => idx % 2 === 0).forEach((pos) => { platformPlaced.forEach((pos) => addCandidate(pos.x, pos.y));
Phaser.Utils.Array.Shuffle(obstacleCandidates);
obstacleCandidates.forEach((pos) => {
if (obstacleCount >= targetObstacleCount) return;
if (isTooClose(pos.x, pos.y)) return;
const obstacle = this.physics.add.sprite(pos.x, pos.y, 'obstacle_mushroom'); const obstacle = this.physics.add.sprite(pos.x, pos.y, 'obstacle_mushroom');
obstacle.setOrigin(0.5, 1); obstacle.setOrigin(0.5, 1);
obstacle.setImmovable(true); obstacle.setImmovable(true);
@@ -368,17 +461,16 @@ export class GameScene extends Phaser.Scene {
body.setOffset((obstacle.displayWidth - 45) / 2, obstacle.displayHeight - 81); body.setOffset((obstacle.displayWidth - 45) / 2, obstacle.displayHeight - 81);
this.obstacles!.add(obstacle); this.obstacles!.add(obstacle);
obstacleCount += 1;
reserve(pos.x, pos.y, 50);
}); });
// SUPER TRÉSORS (rares et précieux - 1 par zone) if (obstacleCount < targetObstacleCount) {
const superTreasurePositions = [ console.warn(
{ x: 1000, y: height - 350 }, // Zone 1 - en hauteur `⚠️ Champignons insuffisants: ${obstacleCount}/${targetObstacleCount}. ` +
{ x: 2500, y: height - 420 }, // Zone 2 - très haut 'Augmentez les positions candidates ou reduisez le minObjectSpacing.'
{ x: 3900, y: height - 450 }, // Zone 3 - très haut );
{ x: 5400, y: height - 470 }, // Zone 4 - ultra haut }
{ x: 6800, y: height - 500 }, // Zone 5 - ultra haut
{ x: 7300, y: height - 250 }, // Zone 6 - sur plateforme finale
];
superTreasurePositions.forEach((pos) => { superTreasurePositions.forEach((pos) => {
const superTreasure = new SuperTreasure(this, pos.x, pos.y); const superTreasure = new SuperTreasure(this, pos.x, pos.y);
@@ -386,10 +478,32 @@ export class GameScene extends Phaser.Scene {
}); });
// COFFRE FINAL au bout du niveau // COFFRE FINAL au bout du niveau
this.treasureChest = new TreasureChest(this, 7700, height - 300, CHEST_REQUIRED_GIFTS); this.treasureChest = new TreasureChest(this, chestPosition.x, chestPosition.y, CHEST_REQUIRED_GIFTS);
this.physics.add.overlap(this.player!, this.treasureChest, this.openChest, undefined, this); this.physics.add.overlap(this.player!, this.treasureChest, this.openChest, undefined, this);
console.log(`${giftPositions.length} cadeaux, ${obstaclePositions.length} obstacles, ${superTreasurePositions.length} SUPER TRÉSORS et 1 COFFRE FINAL créés`); // CAGE avec le chien à la toute fin
this.cage = new Cage(this, 10100, height - 150);
console.log(`${giftPositions.length} cadeaux, ${obstaclePositions.length} obstacles, ${superTreasurePositions.length} SUPER TRÉSORS, 1 COFFRE FINAL et 1 CAGE créés`);
}
/**
* Débloque l'audio sur iOS (requis après interaction utilisateur)
*/
private unlockAudio(): void {
// Sur iOS, l'audio doit être activé après une interaction utilisateur
const soundManager = this.sound as Phaser.Sound.WebAudioSoundManager;
if (soundManager.context) {
// Si le contexte est suspendu, le reprendre
if (soundManager.context.state === 'suspended') {
soundManager.context.resume().then(() => {
console.log('[GameScene Audio] ✅ Context audio débloqué pour iOS');
}).catch((error: any) => {
console.error('[GameScene Audio] ❌ Erreur déblocage audio:', error);
});
}
}
} }
/** /**
@@ -401,6 +515,8 @@ export class GameScene extends Phaser.Scene {
// Bouton de saut // Bouton de saut
this.jumpButton = new JumpButton(this, () => { this.jumpButton = new JumpButton(this, () => {
// IMPORTANT: Débloquer l'audio sur iOS lors de l'interaction
this.unlockAudio();
this.player?.jump(); this.player?.jump();
}); });
@@ -485,6 +601,8 @@ export class GameScene extends Phaser.Scene {
backButton.setDepth(100); backButton.setDepth(100);
backButton.setInteractive({ useHandCursor: true }); backButton.setInteractive({ useHandCursor: true });
backButton.on('pointerdown', () => { backButton.on('pointerdown', () => {
// IMPORTANT: Débloquer l'audio sur iOS
this.unlockAudio();
this.cleanup(); this.cleanup();
this.scene.start('MenuScene'); this.scene.start('MenuScene');
}); });
@@ -501,6 +619,8 @@ export class GameScene extends Phaser.Scene {
this.volumeText.setDepth(100); this.volumeText.setDepth(100);
this.volumeText.setInteractive({ useHandCursor: true }); this.volumeText.setInteractive({ useHandCursor: true });
this.volumeText.on('pointerdown', () => { this.volumeText.on('pointerdown', () => {
// IMPORTANT: Débloquer l'audio sur iOS
this.unlockAudio();
this.cycleMusicVolume(); this.cycleMusicVolume();
}); });
@@ -516,10 +636,247 @@ export class GameScene extends Phaser.Scene {
this.sfxVolumeText.setDepth(100); this.sfxVolumeText.setDepth(100);
this.sfxVolumeText.setInteractive({ useHandCursor: true }); this.sfxVolumeText.setInteractive({ useHandCursor: true });
this.sfxVolumeText.on('pointerdown', () => { this.sfxVolumeText.on('pointerdown', () => {
// IMPORTANT: Débloquer l'audio sur iOS
this.unlockAudio();
this.cycleSfxVolume(); this.cycleSfxVolume();
}); });
} }
private createDebugOverlay(): void {
if (!this.isMobile) return;
this.debugText = this.add.text(10, 130, '', {
fontSize: '12px',
color: '#ffffff',
backgroundColor: '#000000',
padding: { x: 6, y: 6 },
});
this.debugText.setOrigin(0, 0);
this.debugText.setScrollFactor(0);
this.debugText.setDepth(200);
this.gyroDebugButton = this.add.text(10, 70, 'Autoriser gyroscope', {
fontSize: '12px',
color: '#00ff00',
backgroundColor: '#000000',
padding: { x: 6, y: 4 },
});
this.gyroDebugButton.setOrigin(0, 0);
this.gyroDebugButton.setScrollFactor(0);
this.gyroDebugButton.setDepth(200);
this.gyroDebugButton.setInteractive({ useHandCursor: true });
this.gyroDebugButton.on('pointerdown', () => {
// IMPORTANT: Débloquer l'audio sur iOS
this.unlockAudio();
this.requestGyroPermission();
});
}
private createGyroDomButton(): void {
if (!this.isMobile || this.gyroDomButton) return;
const button = document.createElement('button');
button.textContent = 'Autoriser gyroscope';
button.style.position = 'fixed';
button.style.right = '8px';
button.style.top = '8px';
button.style.zIndex = '9999';
button.style.padding = '6px 10px';
button.style.fontSize = '12px';
button.style.background = '#000000';
button.style.color = '#00ff00';
button.style.border = '1px solid #00ff00';
button.style.borderRadius = '4px';
button.style.cursor = 'pointer';
button.style.opacity = '0.9';
button.addEventListener('click', () => {
this.requestGyroPermission();
});
document.body.appendChild(button);
this.gyroDomButton = button;
this.events.once('shutdown', () => {
this.removeGyroDomButton();
});
this.events.once('destroy', () => {
this.removeGyroDomButton();
});
}
private removeGyroDomButton(): void {
if (!this.gyroDomButton) return;
this.gyroDomButton.remove();
this.gyroDomButton = undefined;
}
private createRecalibrateDomButton(): void {
if (!this.isMobile || this.recalibrateDomButton) return;
// Détecter iOS
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// Bouton de recalibration uniquement sur iOS
if (!isIOS) return;
const button = document.createElement('button');
button.textContent = '🔄 Recalibrer';
button.style.position = 'fixed';
button.style.right = '8px';
button.style.top = '48px'; // En dessous du bouton gyroscope
button.style.zIndex = '9999';
button.style.padding = '8px 12px';
button.style.fontSize = '14px';
button.style.background = '#FF9500';
button.style.color = '#ffffff';
button.style.border = '2px solid #ffffff';
button.style.borderRadius = '6px';
button.style.cursor = 'pointer';
button.style.opacity = '0.95';
button.style.fontWeight = 'bold';
button.addEventListener('click', () => {
this.recalibrateGyroscope();
});
document.body.appendChild(button);
this.recalibrateDomButton = button;
this.events.once('shutdown', () => {
this.removeRecalibrateDomButton();
});
this.events.once('destroy', () => {
this.removeRecalibrateDomButton();
});
}
private removeRecalibrateDomButton(): void {
if (!this.recalibrateDomButton) return;
this.recalibrateDomButton.remove();
this.recalibrateDomButton = undefined;
}
private recalibrateGyroscope(): void {
if (!this.gyroControl) return;
// Afficher un message temporaire
const message = document.createElement('div');
message.textContent = '📱 Maintenez le téléphone en position neutre...';
message.style.position = 'fixed';
message.style.top = '50%';
message.style.left = '50%';
message.style.transform = 'translate(-50%, -50%)';
message.style.zIndex = '10000';
message.style.padding = '20px 30px';
message.style.fontSize = '18px';
message.style.background = 'rgba(0, 0, 0, 0.9)';
message.style.color = '#ffffff';
message.style.border = '3px solid #FF9500';
message.style.borderRadius = '12px';
message.style.fontWeight = 'bold';
message.style.textAlign = 'center';
document.body.appendChild(message);
// Recalibrer après un court délai (laisser le temps à l'utilisateur de voir le message)
setTimeout(() => {
this.gyroControl?.calibrate();
message.textContent = '✅ Calibration en cours...';
// Retirer le message après 3 secondes (temps de calibration: 20 échantillons)
setTimeout(() => {
message.remove();
}, 3000);
}, 1000);
}
private showInitialCalibrationMessage(): void {
const message = document.createElement('div');
message.innerHTML = '📱 <strong>Calibration automatique</strong><br><br>Maintenez le téléphone en position neutre pendant 2 secondes...<br><br><small>Utilisez le bouton "🔄 Recalibrer" pour réinitialiser si besoin</small>';
message.style.position = 'fixed';
message.style.top = '50%';
message.style.left = '50%';
message.style.transform = 'translate(-50%, -50%)';
message.style.zIndex = '10000';
message.style.padding = '25px 35px';
message.style.fontSize = '16px';
message.style.background = 'rgba(0, 0, 0, 0.95)';
message.style.color = '#ffffff';
message.style.border = '3px solid #4CAF50';
message.style.borderRadius = '12px';
message.style.fontWeight = 'normal';
message.style.textAlign = 'center';
message.style.lineHeight = '1.5';
document.body.appendChild(message);
// Retirer le message après 4 secondes
setTimeout(() => {
message.remove();
}, 4000);
}
private async requestGyroPermission(): Promise<void> {
const hasDeviceOrientation =
typeof window.DeviceOrientationEvent !== 'undefined';
const requestOrientationPermission =
(window.DeviceOrientationEvent as any)?.requestPermission;
const requestMotionPermission =
(window.DeviceMotionEvent as any)?.requestPermission;
const isIOS = /iP(ad|hone|od)/.test(navigator.userAgent);
const userActivation = (navigator as any).userActivation;
if (isIOS && !window.isSecureContext) {
this.registry.set('gyroPermission', 'blocked');
return;
}
if (hasDeviceOrientation && typeof requestOrientationPermission === 'function') {
try {
const orientationStatus = await requestOrientationPermission.call(
window.DeviceOrientationEvent
);
let motionStatus = 'granted';
if (typeof requestMotionPermission === 'function') {
motionStatus = await requestMotionPermission.call(
window.DeviceMotionEvent
);
}
if (orientationStatus === 'granted' && motionStatus === 'granted') {
this.registry.set('gyroPermission', 'granted');
this.removeGyroDomButton();
// Afficher un message de calibration pour iOS
if (isIOS) {
this.showInitialCalibrationMessage();
}
return;
}
const permState =
orientationStatus === 'granted' || motionStatus === 'granted'
? 'partial'
: 'denied';
this.registry.set('gyroPermission', permState);
this.registry.set(
'gyroPermissionError',
`activation ${userActivation?.isActive}/${userActivation?.hasBeenActive}`
);
return;
} catch (error) {
const errorText =
error instanceof Error ? error.message : String(error);
this.registry.set('gyroPermission', 'error');
this.registry.set(
'gyroPermissionError',
`${errorText} (activation ${userActivation?.isActive}/${userActivation?.hasBeenActive})`
);
return;
}
}
this.registry.set('gyroPermission', 'not-required');
}
update(time: number): void { update(time: number): void {
if (!this.player) return; if (!this.player) return;
@@ -529,36 +886,85 @@ export class GameScene extends Phaser.Scene {
// Gestion des contrôles // Gestion des contrôles
let direction = 0; let direction = 0;
// PC : Clavier // PC/Laptop : Clavier (priorité absolue)
if (this.cursors) { if (!this.isMobile && this.cursors) {
if (this.cursors.left.isDown) { if (this.cursors.left.isDown) {
direction = -1; direction = -1;
if (time % 1000 < 16) console.log('⬅️ Left pressed');
} else if (this.cursors.right.isDown) { } else if (this.cursors.right.isDown) {
direction = 1; direction = 1;
if (time % 1000 < 16) console.log('➡️ Right pressed');
} }
// Saut avec Espace // Saut avec Espace
if (Phaser.Input.Keyboard.JustDown(this.cursors.space!)) { if (Phaser.Input.Keyboard.JustDown(this.cursors.space!)) {
console.log('🚀 Space pressed - Jump!');
this.player.jump(); this.player.jump();
} }
// Saut avec flèche haut (alternative) // Saut avec flèche haut (alternative)
if (Phaser.Input.Keyboard.JustDown(this.cursors.up!)) { if (Phaser.Input.Keyboard.JustDown(this.cursors.up!)) {
console.log('⬆️ Up pressed - Jump!');
this.player.jump(); this.player.jump();
} }
} else if (!this.isMobile) {
// Debug: pourquoi le clavier ne fonctionne pas?
if (time % 2000 < 16) {
console.log('[GameScene] ⚠️ Keyboard disabled: isMobile=', this.isMobile, 'cursors=', !!this.cursors);
}
} }
// Mobile : boutons directionnels priment, sinon gyroscope // Mobile/Tablette : boutons directionnels priment, sinon gyroscope
if (this.isMobile) { if (this.isMobile) {
// Vérifier d'abord les boutons directionnels (priorité)
const dirButtons = this.directionalButtons?.getDirection() ?? 0; const dirButtons = this.directionalButtons?.getDirection() ?? 0;
if (dirButtons !== 0) { if (dirButtons !== 0) {
direction = dirButtons; direction = dirButtons;
} else if (this.gyroControl) { } else if (this.gyroControl) {
// Sinon utiliser le gyroscope
const tiltValue = this.gyroControl.getTiltValue(); const tiltValue = this.gyroControl.getTiltValue();
direction = tiltValue; // -1 à 1 direction = tiltValue; // -1 à 1
} }
} }
// Debug overlay (throttled)
if (this.debugText && this.gyroControl && time - this.lastDebugUpdate > 500) {
const info = this.gyroControl.getDebugInfo();
const now = Date.now();
const lastEventMs =
info.lastEventAt > 0 ? Math.round(now - info.lastEventAt) : -1;
const permState = this.registry.get('gyroPermission') || 'unknown';
const permError = this.registry.get('gyroPermissionError') || '';
// Déterminer quel axe est utilisé actuellement
const axisUsed = info.useAlpha ? 'ALPHA' : (info.useGamma ? 'GAMMA' : 'BETA');
this.debugText.setText(
[
`perm: ${permState}`,
permError ? `err: ${permError}` : '',
`platform: ${info.platform}`,
`gyro active: ${info.active}`,
`--- AXES (raw values) ---`,
`ALPHA: ${info.alpha.toFixed(1)}° (boussole/Z)`,
`BETA: ${info.beta.toFixed(1)}° (avant/arrière/X)`,
`GAMMA: ${info.gamma.toFixed(1)}° (gauche/droite/Y)`,
`--- CONTROL ---`,
`Axe utilisé: ${axisUsed}`,
`tilt normalized: ${info.tilt.toFixed(2)}`,
`orientation: ${info.angle}°`,
`last event: ${lastEventMs}ms`,
]
.filter((line) => line !== '')
.join('\n')
);
this.lastDebugUpdate = time;
}
if (this.gyroDomButton && this.registry.get('gyroPermission') === 'granted') {
this.removeGyroDomButton();
}
// Déplacer le joueur // Déplacer le joueur
this.player.move(direction); this.player.move(direction);
this.player.update(); this.player.update();
@@ -577,6 +983,43 @@ export class GameScene extends Phaser.Scene {
if (this.background) { if (this.background) {
this.background.tilePositionX = this.cameras.main.scrollX * 0.3; this.background.tilePositionX = this.cameras.main.scrollX * 0.3;
} }
// Vérifier la proximité avec le coffre pour afficher le message si pas assez de cadeaux
if (this.treasureChest && !this.treasureChest.getIsOpen()) {
const distanceToChest = Phaser.Math.Distance.Between(
this.player.x,
this.player.y,
7700, // Position X du coffre
this.cameras.main.height - 180 // Position Y du coffre
);
// Si proche du coffre (moins de 150px) et pas assez de cadeaux, afficher le message
if (distanceToChest < 150 && this.giftsCollected < CHEST_REQUIRED_GIFTS) {
this.treasureChest.showRequirementText();
} else {
this.treasureChest.hideRequirementText();
}
}
// Mise à jour du chien dans la cage
if (this.cage) {
this.cage.update();
// Vérifier si le joueur est près de la cage avec la clé
if (this.hasKey && !this.cage.isOpened()) {
const distance = Phaser.Math.Distance.Between(
this.player.x,
this.player.y,
10100, // Position X de la cage
this.cameras.main.height - 150 // Position Y de la cage
);
// Si le joueur est assez proche (moins de 100px)
if (distance < 100) {
this.openCageAndCompleteLevel();
}
}
}
} }
/** /**
@@ -699,17 +1142,22 @@ export class GameScene extends Phaser.Scene {
this.addScore(500); this.addScore(500);
this.playSfx('sfx_super', 0.6); this.playSfx('sfx_super', 0.6);
// BONUS DE TEMPS : +5 secondes
this.timeRemaining += 5;
console.log('⏱️ Super Trésor : +5 secondes ! Temps restant:', this.timeRemaining);
// Message spécial // Message spécial
const bonusText = this.add.text( const bonusText = this.add.text(
this.cameras.main.scrollX + this.cameras.main.width / 2, this.cameras.main.scrollX + this.cameras.main.width / 2,
this.cameras.main.height / 2, this.cameras.main.height / 2,
'★ SUPER TRÉSOR +500 ★', '★ SUPER TRÉSOR +500 ★\n⏱ +5 SECONDES ⏱️',
{ {
fontSize: '48px', fontSize: '48px',
color: '#FFD700', color: '#FFD700',
stroke: '#FF8C00', stroke: '#FF8C00',
strokeThickness: 6, strokeThickness: 6,
fontStyle: 'bold', fontStyle: 'bold',
align: 'center',
} }
); );
bonusText.setOrigin(0.5); bonusText.setOrigin(0.5);
@@ -733,17 +1181,23 @@ export class GameScene extends Phaser.Scene {
} }
/** /**
* Ouvre le coffre au trésor final * Ouvre le coffre au trésor final et donne la clé
*/ */
private openChest(_player: any, chest: any): void { private openChest(_player: any, chest: any): void {
if (chest.canOpen(this.giftsCollected)) { if (chest.canOpen(this.giftsCollected)) {
const bonus = chest.open(this); const result = chest.open(this);
this.addScore(bonus); this.addScore(result.bonus);
// VICTOIRE ! Lancer l'animation de fin // Donner la clé au joueur avec animation
this.time.delayedCall(2000, () => { if (result.hasKey) {
this.levelComplete(); this.hasKey = true;
}); console.log('🗝️ Clé obtenue ! Direction la cage du chien !');
// Créer une clé animée qui vole vers le joueur
this.createKeyPickupAnimation();
}
// Ne PAS terminer le niveau ici - continuer vers la cage
} else if (!chest.getIsOpen()) { } else if (!chest.getIsOpen()) {
// Pas assez de cadeaux // Pas assez de cadeaux
const remaining = chest.getRequiredGifts() - this.giftsCollected; const remaining = chest.getRequiredGifts() - this.giftsCollected;
@@ -772,6 +1226,139 @@ export class GameScene extends Phaser.Scene {
} }
} }
/**
* Animation de récupération de la clé
*/
private createKeyPickupAnimation(): void {
// Créer une grosse icône de clé au centre de l'écran
const keyIcon = this.add.text(
this.cameras.main.scrollX + this.cameras.main.width / 2,
this.cameras.main.height / 2,
'🗝️',
{
fontSize: '120px',
}
);
keyIcon.setOrigin(0.5);
keyIcon.setScrollFactor(0);
keyIcon.setDepth(10000); // Augmenté pour être au-dessus de tout (neige = 2000)
keyIcon.setAlpha(0);
keyIcon.setScale(0.1);
// Message "Clé obtenue"
const keyText = this.add.text(
this.cameras.main.scrollX + this.cameras.main.width / 2,
this.cameras.main.height / 2 + 100,
'🗝️ CLÉ OBTENUE ! 🗝️\nDirection : Cage du chien →',
{
fontSize: '40px', // Augmenté pour être plus visible
color: '#FFD700',
stroke: '#000000',
strokeThickness: 8, // Augmenté pour meilleure visibilité
fontStyle: 'bold',
align: 'center',
}
);
keyText.setOrigin(0.5);
keyText.setScrollFactor(0);
keyText.setDepth(10000); // Augmenté pour être au-dessus de tout (neige = 2000)
keyText.setAlpha(0);
// Animation de la clé : apparition avec rotation
this.tweens.add({
targets: keyIcon,
scale: 1.5,
alpha: 1,
angle: 360,
duration: 800,
ease: 'Back.easeOut',
});
// Animation du texte : apparition
this.tweens.add({
targets: keyText,
alpha: 1,
duration: 600,
delay: 400,
ease: 'Power2',
});
// Après 2 secondes, faire disparaître tout
this.time.delayedCall(2500, () => {
this.tweens.add({
targets: [keyIcon, keyText],
alpha: 0,
scale: 0.5,
duration: 500,
ease: 'Power2',
onComplete: () => {
keyIcon.destroy();
keyText.destroy();
},
});
});
// Flash doré pour attirer l'attention
this.cameras.main.flash(300, 255, 215, 0, true);
// Son (si disponible)
this.playSfx('sfx_powerup', 0.7);
}
/**
* Ouvre la cage avec la clé et termine le niveau
*/
private openCageAndCompleteLevel(): void {
if (!this.cage || this.cage.isOpened()) return;
console.log('🗝️ Ouverture de la cage avec la clé !');
// Ouvrir la cage
this.cage.open();
// Jouer la musique du chien libéré
this.playSfx('sfx_chien', 0.7);
// Message de libération
const liberationText = this.add.text(
this.cameras.main.scrollX + this.cameras.main.width / 2,
this.cameras.main.height / 2 - 100,
'🐕 CHIEN LIBÉRÉ ! 🐕\n🎉 NIVEAU TERMINÉ ! 🎉',
{
fontSize: '48px',
color: '#FFD700',
stroke: '#FF4500',
strokeThickness: 8,
fontStyle: 'bold',
align: 'center',
}
);
liberationText.setOrigin(0.5);
liberationText.setScrollFactor(0);
liberationText.setDepth(2000);
// Flash de victoire
this.cameras.main.flash(500, 255, 215, 0, true);
// Animation du texte
this.tweens.add({
targets: liberationText,
scaleX: 1.3,
scaleY: 1.3,
alpha: 0,
duration: 3000,
ease: 'Power2',
onComplete: () => {
liberationText.destroy();
},
});
// Après 3 secondes, terminer le niveau
this.time.delayedCall(3000, () => {
this.levelComplete();
});
}
/** /**
* Animation de victoire - Niveau terminé ! * Animation de victoire - Niveau terminé !
*/ */

View File

@@ -0,0 +1,171 @@
import Phaser from 'phaser';
/**
* Scene de lecture de la vidéo d'intro en mode paysage (User1 uniquement).
*/
export class IntroVideoScene extends Phaser.Scene {
private video?: Phaser.GameObjects.Video;
private playButton?: Phaser.GameObjects.Container;
private hasFinished: boolean = false;
constructor() {
super({ key: 'IntroVideoScene' });
}
create(): void {
this.cameras.main.setBackgroundColor('#000000');
this.showPlayButton();
}
private showPlayButton(): void {
const { width, height } = this.cameras.main;
this.playButton = this.add.container(width / 2, height / 2);
const circle = this.add.circle(0, 0, 60, 0x4CAF50, 1);
circle.setStrokeStyle(4, 0xffffff);
const triangle = this.add.triangle(5, 0, -15, -20, -15, 20, 20, 0, 0xffffff);
const text = this.add.text(0, 100, 'Appuyez pour demarrer la video', {
fontSize: '20px',
color: '#ffffff',
align: 'center',
fontFamily: 'Arial',
});
text.setOrigin(0.5);
this.playButton.add([circle, triangle, text]);
circle.setInteractive({ useHandCursor: true });
circle.on('pointerdown', () => {
this.playButton?.destroy();
this.playButton = undefined;
this.playIntroVideo();
});
}
private playIntroVideo(): void {
const { width, height } = this.cameras.main;
// Déterminer quelle vidéo jouer selon le joueur sélectionné
const selectedPlayer = this.registry.get('selectedPlayer') as string | undefined;
const videoKey = selectedPlayer === 'user2' ? 'intro_user2' : 'intro_user1';
console.log('[IntroVideoScene] Lecture de la vidéo:', videoKey);
console.log('[IntroVideoScene] Vidéo dans cache?', this.cache.video.exists(videoKey));
if (!this.cache.video.exists(videoKey)) {
console.warn(`[IntroVideoScene] Vidéo ${videoKey} non trouvée, passage au menu`);
this.gotoMenu();
return;
}
console.log('[IntroVideoScene] Création de l\'objet vidéo');
this.video = this.add.video(width / 2, height / 2, videoKey);
this.video.setOrigin(0.5);
this.video.setDepth(1000);
// Attendre que les métadonnées soient chargées
this.video.on('metadata', () => {
if (!this.video) return;
const videoWidth = this.video.video?.videoWidth || 1280;
const videoHeight = this.video.video?.videoHeight || 720;
console.log('[IntroVideoScene] Métadonnées vidéo chargées:', videoWidth, 'x', videoHeight);
this.video.setSize(videoWidth, videoHeight);
this.updateVideoSize();
});
// Activer l'audio
this.video.setMute(false);
this.video.setLoop(false);
console.log('[IntroVideoScene] Démarrage de la lecture');
// IMPORTANT: Utiliser play(true) pour l'autoplay après interaction utilisateur (requis pour iOS)
const started = this.video.play(true);
console.log('[IntroVideoScene] Lecture démarrée?', started);
if (!started) {
console.warn('[IntroVideoScene] Lecture vidéo bloquée → passage au menu');
this.gotoMenu();
return;
}
// Événement de fin de vidéo
this.video.once('complete', () => {
console.log('[IntroVideoScene] Vidéo terminée (événement complete) → passage au menu');
this.gotoMenu();
});
// Événement alternatif 'stop' au cas où 'complete' ne se déclenche pas
this.video.once('stop', () => {
console.log('[IntroVideoScene] Vidéo arrêtée (événement stop) → passage au menu');
this.gotoMenu();
});
this.video.once('error', (err: any) => {
console.error('[IntroVideoScene] Erreur lecture vidéo:', err);
this.gotoMenu();
});
// Timer de sécurité basé sur la durée de la vidéo
// IMPORTANT pour iOS où 'complete' ne se déclenche pas toujours
this.video.on('metadata', () => {
if (!this.video || !this.video.video) return;
const duration = this.video.getDuration();
console.log('[IntroVideoScene] Durée de la vidéo:', duration, 'secondes');
// Timer de sécurité : durée vidéo + 0.5 seconde
this.time.delayedCall((duration + 0.5) * 1000, () => {
if (!this.hasFinished) {
console.warn('[IntroVideoScene] Timer de sécurité (metadata) déclenché → passage au menu');
this.gotoMenu();
}
});
});
// Timer de sécurité fixe au cas où les métadonnées ne se chargent pas
// Vidéo d'intro = 8 secondes + 1 seconde de marge
this.time.delayedCall(9000, () => {
if (!this.hasFinished) {
console.warn('[IntroVideoScene] Timer de sécurité fixe (9s) déclenché → passage au menu');
this.gotoMenu();
}
});
// Ajuster si resize
this.scale.on('resize', (gameSize: Phaser.Structs.Size) => {
if (this.video && this.video.isPlaying()) {
this.updateVideoSize(gameSize.width, gameSize.height);
}
});
}
private updateVideoSize(targetW?: number, targetH?: number): void {
if (!this.video) return;
const w = targetW ?? this.cameras.main.width;
const h = targetH ?? this.cameras.main.height;
const nativeW = this.video.video?.videoWidth || 1280;
const nativeH = this.video.video?.videoHeight || 720;
const videoRatio = nativeW / nativeH;
const screenRatio = w / h;
let scale: number;
if (screenRatio > videoRatio) {
scale = h / nativeH;
} else {
scale = w / nativeW;
}
this.video.setScale(scale);
this.video.setPosition(w / 2, h / 2);
}
private gotoMenu(): void {
if (this.hasFinished) return;
this.hasFinished = true;
this.video?.stop();
this.video?.destroy();
this.scene.start('MenuScene');
}
}

View File

@@ -7,6 +7,10 @@ export class MenuScene extends Phaser.Scene {
private startButton?: Phaser.GameObjects.Text; private startButton?: Phaser.GameObjects.Text;
private gyroStatus?: Phaser.GameObjects.Text; private gyroStatus?: Phaser.GameObjects.Text;
private hasStarted: boolean = false; private hasStarted: boolean = false;
private debugText?: Phaser.GameObjects.Text;
private debugLines: string[] = [];
private hasGyroDebugListener: boolean = false;
private isRequestingPermission: boolean = false;
constructor() { constructor() {
super({ key: 'MenuScene' }); super({ key: 'MenuScene' });
@@ -18,6 +22,8 @@ export class MenuScene extends Phaser.Scene {
// Réinitialiser le flag à chaque création du menu // Réinitialiser le flag à chaque création du menu
this.hasStarted = false; this.hasStarted = false;
this.registry.set('gyroPermission', 'unknown');
this.registry.set('gyroPermissionError', '');
// Titre // Titre
const title = this.add.text(width / 2, height / 3, 'MARIO RUNNER', { const title = this.add.text(width / 2, height / 3, 'MARIO RUNNER', {
@@ -64,8 +70,28 @@ export class MenuScene extends Phaser.Scene {
}); });
this.gyroStatus.setOrigin(0.5); this.gyroStatus.setOrigin(0.5);
// Click sur le bouton // Debug gyroscope (logs a l'ecran)
this.debugText = this.add.text(10, height - 110, '', {
fontSize: '12px',
color: '#ffffff',
backgroundColor: '#000000',
padding: { x: 6, y: 6 },
wordWrap: { width: width - 20 },
});
this.debugText.setOrigin(0, 0);
this.debugText.setScrollFactor(0);
// Demande iOS dès le premier tap (exigé par la permission).
this.input.once('pointerdown', () => {
// IMPORTANT: Débloquer l'audio sur iOS
this.unlockAudio();
this.requestGyroPermission();
});
// Click sur le bouton (fallback si le tap global a déjà eu lieu).
this.startButton.on('pointerdown', () => { this.startButton.on('pointerdown', () => {
// IMPORTANT: Débloquer l'audio sur iOS
this.unlockAudio();
this.requestGyroPermission(); this.requestGyroPermission();
}); });
@@ -79,13 +105,168 @@ export class MenuScene extends Phaser.Scene {
}); });
} }
/**
* Débloque l'audio sur iOS (requis après interaction utilisateur)
*/
private unlockAudio(): void {
// Sur iOS, l'audio doit être activé après une interaction utilisateur
const soundManager = this.sound as Phaser.Sound.WebAudioSoundManager;
if (soundManager.context) {
console.log('[Audio] Context state:', soundManager.context.state);
// Si le contexte est suspendu, le reprendre
if (soundManager.context.state === 'suspended') {
soundManager.context.resume().then(() => {
console.log('[Audio] ✅ Context audio débloqué pour iOS');
}).catch((error: any) => {
console.error('[Audio] ❌ Erreur déblocage audio:', error);
});
}
}
// Jouer un son silencieux pour initialiser l'audio sur mobile
// Cela garantit que les sons futurs fonctionneront
try {
const silentSound = this.sound.add('__silent__', { volume: 0 });
silentSound.play();
silentSound.destroy();
} catch (error: any) {
console.log('[Audio] Pas de son silencieux (normal)');
}
}
/** /**
* Demande la permission gyroscope (iOS) et lance le jeu * Demande la permission gyroscope (iOS) et lance le jeu
*/ */
private requestGyroPermission(): void { private async requestGyroPermission(): Promise<void> {
// Simplifié : on part immédiatement en jeu, sans attendre la permission. if (this.isRequestingPermission) {
this.gyroStatus?.setText('Lancement du jeu...'); this.logDebug('permission request in progress');
return;
}
this.isRequestingPermission = true;
try {
console.log('[Gyro] request permission');
console.log('[Gyro] userAgent:', navigator.userAgent);
console.log('[Gyro] isSecureContext:', window.isSecureContext);
this.gyroStatus?.setText("Demande d'autorisation du gyroscope...");
const hasDeviceOrientation =
typeof window.DeviceOrientationEvent !== 'undefined';
const requestOrientationPermission =
(window.DeviceOrientationEvent as any)?.requestPermission;
const requestMotionPermission =
(window.DeviceMotionEvent as any)?.requestPermission;
const isIOS = /iP(ad|hone|od)/.test(navigator.userAgent);
const userActivation = (navigator as any).userActivation;
this.logDebug(`ua: ${navigator.userAgent}`);
this.logDebug(`secure: ${window.isSecureContext}`);
this.logDebug(`iOS: ${isIOS}`);
this.logDebug(`DeviceOrientationEvent: ${hasDeviceOrientation}`);
this.logDebug(
`requestPermission: ${typeof requestOrientationPermission === 'function'}`
);
this.logDebug(
`activation: ${userActivation?.isActive}/${userActivation?.hasBeenActive}`
);
this.logDebug(`top-level: ${window.top === window.self}`);
this.logDebug(`visible: ${document.visibilityState}`);
this.setupGyroDebugListener();
if (isIOS && !window.isSecureContext) {
this.gyroStatus?.setText(
'Le gyroscope iOS requiert HTTPS. Ouvrez le jeu en https.'
);
this.logDebug('blocked: not https');
this.registry.set('gyroPermission', 'blocked');
return;
}
// iOS 13+ requires explicit permission from a user gesture.
if (hasDeviceOrientation && typeof requestOrientationPermission === 'function') {
try {
const orientationStatus = await requestOrientationPermission.call(
window.DeviceOrientationEvent
);
let motionStatus = 'granted';
if (typeof requestMotionPermission === 'function') {
motionStatus = await requestMotionPermission.call(
window.DeviceMotionEvent
);
}
console.log('[Gyro] permission orientation:', orientationStatus);
console.log('[Gyro] permission motion:', motionStatus);
this.logDebug(`perm orientation: ${orientationStatus}`);
this.logDebug(`perm motion: ${motionStatus}`);
if (orientationStatus === 'granted' && motionStatus === 'granted') {
this.registry.set('gyroPermission', 'granted');
this.gyroStatus?.setText('Gyroscope activé. Lancement...');
this.startGame();
return;
}
this.gyroStatus?.setText(
'Acces au gyroscope refuse. Activez-le dans les reglages iOS.'
);
const permState =
orientationStatus === 'granted' || motionStatus === 'granted'
? 'partial'
: 'denied';
this.registry.set('gyroPermission', permState);
this.logDebug('permission refused');
this.logDebug('starting without gyro');
this.startGame();
return;
} catch (error) {
console.warn('[MenuScene] Permission gyroscope refusée', error);
this.gyroStatus?.setText(
"Impossible d'activer le gyroscope. Reessayez."
);
const errorText =
error instanceof Error ? error.message : String(error);
this.logDebug(`permission error: ${errorText}`);
this.registry.set('gyroPermission', 'error');
this.registry.set('gyroPermissionError', errorText);
this.logDebug('starting without gyro');
this.startGame();
return;
}
}
// Android/desktop or older iOS: no permission API.
this.registry.set('gyroPermission', 'not-required');
this.gyroStatus?.setText('Gyroscope prêt. Lancement...');
this.startGame(); this.startGame();
} finally {
this.isRequestingPermission = false;
}
}
private logDebug(line: string): void {
this.debugLines.push(line);
if (this.debugLines.length > 6) {
this.debugLines.shift();
}
this.debugText?.setText(this.debugLines.join('\n'));
}
private setupGyroDebugListener(): void {
if (this.hasGyroDebugListener) return;
this.hasGyroDebugListener = true;
let lastLogTime = 0;
window.addEventListener('deviceorientation', (event) => {
const now = Date.now();
if (now - lastLogTime < 1000) return;
lastLogTime = now;
const beta = event.beta ?? 0;
const gamma = event.gamma ?? 0;
this.logDebug(`event beta:${beta.toFixed(1)} gamma:${gamma.toFixed(1)}`);
});
} }
/** /**

Some files were not shown because too many files have changed in this diff Show More