/** * 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(); };