- Body SolidWorks v1 → 02_PRODUITS_PHYSIQUES/Microphone/Conception/ - Studio Mic KiCAD (DIYPerks) → 02_PRODUITS_PHYSIQUES/R&D_References/DIY/ - cleanup_ports.sh → 04_INFRA_DEPLOIEMENT/ - mockup_jeu_ux → 11_RECHERCHE_&_LAB/ - Printables → 12_DOCUMENTATION/Imprimables/ - Screenshots, ideas, one.html → _BROUILLON/ - all-talas (23Go) → 13_ARCHIVES/ - Supprimé all-talas.zip (20Go doublon), lock files LibreOffice - Nettoyé .gitignore - Remote → Forgejo (10.0.20.105:3000) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
383 lines
No EOL
12 KiB
JavaScript
383 lines
No EOL
12 KiB
JavaScript
/**
|
|
* game.js
|
|
* Moteur 2D Top-Down V2 - Architecture Orientée Objet
|
|
* Tilemap, Caméra, Collisions & Interactions (Raycasting)
|
|
*/
|
|
|
|
// --- 1. Configuration Globale ---
|
|
const TILE_SIZE = 64;
|
|
const CANVAS_WIDTH = 800;
|
|
const CANVAS_HEIGHT = 600;
|
|
|
|
// --- 2. Gestion des Entrées (Input) ---
|
|
const keys = {
|
|
w: false, a: false, s: false, d: false,
|
|
ArrowUp: false, ArrowLeft: false, ArrowDown: false, ArrowRight: false
|
|
};
|
|
|
|
// Écouteurs globaux pour le clavier (ZQSD / WASD / Flèches)
|
|
window.addEventListener('keydown', (e) => {
|
|
if (keys.hasOwnProperty(e.key)) keys[e.key] = true;
|
|
if (e.code === 'KeyW' || e.code === 'KeyZ') keys.w = true; // Z pour AZERTY, W pour QWERTY
|
|
if (e.code === 'KeyA' || e.code === 'KeyQ') keys.a = true; // Q pour AZERTY, A pour QWERTY
|
|
if (e.code === 'KeyS') keys.s = true;
|
|
if (e.code === 'KeyD') keys.d = true;
|
|
});
|
|
|
|
window.addEventListener('keyup', (e) => {
|
|
if (keys.hasOwnProperty(e.key)) keys[e.key] = false;
|
|
if (e.code === 'KeyW' || e.code === 'KeyZ') keys.w = false;
|
|
if (e.code === 'KeyA' || e.code === 'KeyQ') keys.a = false;
|
|
if (e.code === 'KeyS') keys.s = false;
|
|
if (e.code === 'KeyD') keys.d = false;
|
|
});
|
|
|
|
// --- 3. Classes de l'Architecture ---
|
|
|
|
/**
|
|
* Gère la position de la vue (Caméra) dans le monde
|
|
*/
|
|
class Camera {
|
|
constructor(width, height, mapWidth, mapHeight) {
|
|
this.x = 0;
|
|
this.y = 0;
|
|
this.width = width;
|
|
this.height = height;
|
|
this.mapWidth = mapWidth;
|
|
this.mapHeight = mapHeight;
|
|
}
|
|
|
|
update(target) {
|
|
// La caméra cible le centre de l'entité (le joueur)
|
|
this.x = target.x + target.width / 2 - this.width / 2;
|
|
this.y = target.y + target.height / 2 - this.height / 2;
|
|
|
|
// Culling de la caméra : on l'empêche de sortir des limites de la Tilemap
|
|
this.x = Math.max(0, Math.min(this.x, this.mapWidth - this.width));
|
|
this.y = Math.max(0, Math.min(this.y, this.mapHeight - this.height));
|
|
}
|
|
|
|
// Convertit des coordonnées écran (clic souris) en coordonnées monde absolues
|
|
screenToWorld(screenX, screenY) {
|
|
return {
|
|
x: screenX + this.x,
|
|
y: screenY + this.y
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gère la carte, le tableau 2D (grille) et le rendu des tuiles
|
|
*/
|
|
class TileMap {
|
|
constructor() {
|
|
this.tileSize = TILE_SIZE;
|
|
// Création d'une carte de 1600x1216 pixels (25x19 tuiles)
|
|
this.cols = 25;
|
|
this.rows = 19;
|
|
this.width = this.cols * this.tileSize;
|
|
this.height = this.rows * this.tileSize;
|
|
|
|
this.data = this.generateMap();
|
|
|
|
// Palette de couleurs pour la V2 (en attendant les sprites)
|
|
this.colors = {
|
|
0: '#6ab04c', // Herbe (Vert clair)
|
|
1: '#eb4d4b', // Chemin de terre (Marron/Rouge)
|
|
2: '#535c68' // Mur / Obstacle (Gris)
|
|
};
|
|
}
|
|
|
|
generateMap() {
|
|
const map = [];
|
|
for (let y = 0; y < this.rows; y++) {
|
|
const row = [];
|
|
for (let x = 0; x < this.cols; x++) {
|
|
// Créer des murs sur toutes les bordures extérieures
|
|
if (x === 0 || x === this.cols - 1 || y === 0 || y === this.rows - 1) {
|
|
row.push(2);
|
|
} else {
|
|
// Remplissage aléatoire simple du centre
|
|
const rand = Math.random();
|
|
if (rand < 0.15) row.push(2); // 15% de murs/obstacles
|
|
else if (rand < 0.35) row.push(1); // 20% de chemins
|
|
else row.push(0); // 65% d'herbe
|
|
}
|
|
}
|
|
map.push(row);
|
|
}
|
|
|
|
// S'assurer de libérer une "clairière" au centre pour que le joueur y apparaisse sans être bloqué
|
|
const midY = Math.floor(this.rows / 2);
|
|
const midX = Math.floor(this.cols / 2);
|
|
for(let dy = -2; dy <= 2; dy++) {
|
|
for(let dx = -2; dx <= 2; dx++) {
|
|
map[midY + dy][midX + dx] = 0; // Herbe au centre
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
// Rend uniquement les tuiles visibles par la caméra (Optimisation: Culling)
|
|
draw(ctx, camera) {
|
|
const startCol = Math.floor(camera.x / this.tileSize);
|
|
const endCol = startCol + (camera.width / this.tileSize) + 1;
|
|
const startRow = Math.floor(camera.y / this.tileSize);
|
|
const endRow = startRow + (camera.height / this.tileSize) + 1;
|
|
|
|
for (let y = startRow; y < endRow; y++) {
|
|
for (let x = startCol; x < endCol; x++) {
|
|
// Vérifier si la tuile existe bien dans le tableau pour éviter les erreurs
|
|
if (y >= 0 && y < this.rows && x >= 0 && x < this.cols) {
|
|
const tileType = this.data[y][x];
|
|
ctx.fillStyle = this.colors[tileType];
|
|
|
|
// Calcul de la position de dessin en soustrayant la position de la caméra
|
|
const drawX = Math.round(x * this.tileSize - camera.x);
|
|
const drawY = Math.round(y * this.tileSize - camera.y);
|
|
|
|
ctx.fillRect(drawX, drawY, this.tileSize, this.tileSize);
|
|
|
|
// Ligne optionnelle pour afficher la grille
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.05)';
|
|
ctx.strokeRect(drawX, drawY, this.tileSize, this.tileSize);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Indique si une position X,Y spécifique du monde est solide (infranchissable)
|
|
isSolid(x, y) {
|
|
const col = Math.floor(x / this.tileSize);
|
|
const row = Math.floor(y / this.tileSize);
|
|
|
|
// Sortir de la map compte comme un mur
|
|
if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) {
|
|
return true;
|
|
}
|
|
// La tuile n°2 est notre identifiant pour "Mur"
|
|
return this.data[row][col] === 2;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* L'avatar contrôlé par l'utilisateur
|
|
*/
|
|
class Player {
|
|
constructor(x, y) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.width = 32; // Plus petit que la tuile (64)
|
|
this.height = 32;
|
|
this.color = '#f9ca24'; // Jaune vif
|
|
this.speed = 5;
|
|
}
|
|
|
|
update(map) {
|
|
let dx = 0;
|
|
let dy = 0;
|
|
|
|
// Déterminer le vecteur de direction désiré
|
|
if (keys.w || keys.ArrowUp) dy -= this.speed;
|
|
if (keys.s || keys.ArrowDown) dy += this.speed;
|
|
if (keys.a || keys.ArrowLeft) dx -= this.speed;
|
|
if (keys.d || keys.ArrowRight) dx += this.speed;
|
|
|
|
// Normalisation de la vitesse en diagonale
|
|
if (dx !== 0 && dy !== 0) {
|
|
const length = Math.sqrt(dx * dx + dy * dy);
|
|
dx = (dx / length) * this.speed;
|
|
dy = (dy / length) * this.speed;
|
|
}
|
|
|
|
// Système de Collision séparé par axe (AABB)
|
|
// Axe X
|
|
if (dx !== 0) {
|
|
const newX = this.x + dx;
|
|
if (!this.checkCollision(newX, this.y, map)) {
|
|
this.x = newX;
|
|
}
|
|
}
|
|
|
|
// Axe Y
|
|
if (dy !== 0) {
|
|
const newY = this.y + dy;
|
|
if (!this.checkCollision(this.x, newY, map)) {
|
|
this.y = newY;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vérifie si la boîte de collision (Bounding Box) aux futures coordonnées heurte un mur
|
|
checkCollision(newX, newY, map) {
|
|
const left = newX;
|
|
const right = newX + this.width - 1; // -1 pour ne pas compter le pixel adjacent en collision
|
|
const top = newY;
|
|
const bottom = newY + this.height - 1;
|
|
|
|
// Si n'importe lequel des 4 coins touche un mur, il y a collision
|
|
return map.isSolid(left, top) ||
|
|
map.isSolid(right, top) ||
|
|
map.isSolid(left, bottom) ||
|
|
map.isSolid(right, bottom);
|
|
}
|
|
|
|
draw(ctx, camera) {
|
|
ctx.fillStyle = this.color;
|
|
// La position dessinée dépend de l'offset de la caméra
|
|
const drawX = Math.round(this.x - camera.x);
|
|
const drawY = Math.round(this.y - camera.y);
|
|
ctx.fillRect(drawX, drawY, this.width, this.height);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Entité interactive cliquable sur la carte
|
|
*/
|
|
class PointOfInterest {
|
|
constructor(x, y, radius = 24) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.radius = radius;
|
|
this.color = '#0984e3'; // Bleu
|
|
}
|
|
|
|
draw(ctx, camera) {
|
|
const drawX = Math.round(this.x - camera.x);
|
|
const drawY = Math.round(this.y - camera.y);
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(drawX, drawY, this.radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = this.color;
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = '#74b9ff';
|
|
ctx.lineWidth = 4;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Vérifie si un point absolu du monde (px, py) intersecte le cercle (Raycasting basique)
|
|
contains(px, py) {
|
|
const dx = this.x - px;
|
|
const dy = this.y - py;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
return distance <= this.radius;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Classe Chef d'Orchestre du jeu
|
|
*/
|
|
class Game {
|
|
constructor() {
|
|
this.canvas = document.getElementById('gameCanvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
this.map = new TileMap();
|
|
|
|
// Apparition du joueur au centre du monde
|
|
const startX = (this.map.width / 2) - 16;
|
|
const startY = (this.map.height / 2) - 16;
|
|
this.player = new Player(startX, startY);
|
|
|
|
this.camera = new Camera(CANVAS_WIDTH, CANVAS_HEIGHT, this.map.width, this.map.height);
|
|
|
|
// Génération de quelques POIs autour du centre
|
|
this.pois = [
|
|
new PointOfInterest(startX + 200, startY + 50),
|
|
new PointOfInterest(startX - 250, startY + 150),
|
|
new PointOfInterest(startX + 60, startY - 200)
|
|
];
|
|
|
|
this.isPaused = false;
|
|
|
|
this.initUI();
|
|
this.initMouseInput();
|
|
|
|
// Lancer la boucle de jeu
|
|
requestAnimationFrame(() => this.loop());
|
|
}
|
|
|
|
initUI() {
|
|
this.modal = document.getElementById('ui-modal');
|
|
const closeBtn = document.getElementById('close-modal-btn');
|
|
|
|
// Fermer la modale
|
|
closeBtn.addEventListener('click', () => {
|
|
this.modal.classList.add('hidden');
|
|
this.isPaused = false; // Relancer les updates du jeu
|
|
});
|
|
}
|
|
|
|
initMouseInput() {
|
|
this.canvas.addEventListener('mousedown', (e) => {
|
|
if (this.isPaused) return;
|
|
|
|
// 1. Obtenir les coordonnées locales du clic sur le canvas
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
// Facteur de mise à l'échelle si le canvas est redimensionné en CSS
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
|
|
const mouseX = (e.clientX - rect.left) * scaleX;
|
|
const mouseY = (e.clientY - rect.top) * scaleY;
|
|
|
|
// 2. Convertir les coordonnées écran en coordonnées monde
|
|
const worldPos = this.camera.screenToWorld(mouseX, mouseY);
|
|
|
|
// 3. Vérifier l'intersection avec tous les Points d'Intérêts
|
|
for (const poi of this.pois) {
|
|
if (poi.contains(worldPos.x, worldPos.y)) {
|
|
this.triggerEvent(poi);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
triggerEvent(poi) {
|
|
this.isPaused = true;
|
|
this.modal.classList.remove('hidden');
|
|
|
|
// Réinitialiser les touches pour empêcher le joueur de "glisser" à la fermeture
|
|
for (let key in keys) {
|
|
keys[key] = false;
|
|
}
|
|
}
|
|
|
|
update() {
|
|
if (this.isPaused) return;
|
|
|
|
this.player.update(this.map);
|
|
this.camera.update(this.player);
|
|
}
|
|
|
|
draw() {
|
|
// Fond de sécurité
|
|
this.ctx.fillStyle = '#000';
|
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
// 1. Dessiner la Tilemap
|
|
this.map.draw(this.ctx, this.camera);
|
|
|
|
// 2. Dessiner les objets interactifs
|
|
for (const poi of this.pois) {
|
|
poi.draw(this.ctx, this.camera);
|
|
}
|
|
|
|
// 3. Dessiner le Joueur
|
|
this.player.draw(this.ctx, this.camera);
|
|
}
|
|
|
|
loop() {
|
|
this.update();
|
|
this.draw();
|
|
requestAnimationFrame(() => this.loop());
|
|
}
|
|
}
|
|
|
|
// --- Démarrage ---
|
|
window.onload = () => {
|
|
new Game();
|
|
}; |