From 7462dd7fed209b6e2578f318d79e7db79020c3c0 Mon Sep 17 00:00:00 2001 From: marti Date: Sat, 3 Jan 2026 00:15:28 +0100 Subject: [PATCH] Implement manual player movement planning (steps) and hopping animation - GameEngine: Added path planning logic (click to add step, re-click to undo). - GameRenderer: Added path visualization (numbered yellow squares). - GameRenderer: Updated animation to include 'hopping' effect and clear path markers on visit. - UIManager: Replaced alerts with modals. - Main: Wired right-click to execute movement. --- src/engine/game/GameEngine.js | 106 +++++++++++++++++++++++++--------- src/main.js | 9 ++- src/view/GameRenderer.js | 102 +++++++++++++++++++++++++++----- src/view/UIManager.js | 2 +- 4 files changed, 176 insertions(+), 43 deletions(-) diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index b6387a2..1bf3f22 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -9,6 +9,7 @@ export class GameEngine { this.player = null; this.selectedEntity = null; this.isRunning = false; + this.plannedPath = []; // Array of {x,y} // Callbacks this.onEntityUpdate = null; @@ -39,54 +40,105 @@ export class GameEngine { if (this.onEntityUpdate) { this.onEntityUpdate(this.player); } - - } onCellClick(x, y) { - // If no player selected, select player on click - if (!this.selectedEntity && this.player && x === this.player.x && y === this.player.y) { - this.selectedEntity = this.player; - if (this.onEntitySelect) { - this.onEntitySelect(this.player.id, true); + // 1. SELECT / DESELECT PLAYER + if (this.player && x === this.player.x && y === this.player.y) { + if (this.selectedEntity === this.player) { + // Toggle Deselect + this.deselectPlayer(); + } else { + // Select + this.selectedEntity = this.player; + if (this.onEntitySelect) { + this.onEntitySelect(this.player.id, true); + } } - return; } - // If player selected, move to clicked cell + // 2. PLAN MOVEMENT (If player selected) if (this.selectedEntity === this.player) { - if (this.canMoveTo(x, y)) { - this.movePlayer(x, y); - } else { + this.planStep(x, y); + } + } + deselectPlayer() { + this.selectedEntity = null; + this.plannedPath = []; + if (this.onEntitySelect) this.onEntitySelect(this.player.id, false); + if (this.onPathChange) this.onPathChange([]); + } + + planStep(x, y) { + // Determine start point (either current player pos or last planned step) + const lastStep = this.plannedPath.length > 0 + ? this.plannedPath[this.plannedPath.length - 1] + : { x: this.player.x, y: this.player.y }; + + // Check Adjacency + const dx = Math.abs(x - lastStep.x); + const dy = Math.abs(y - lastStep.y); + const isAdjacent = (dx + dy) === 1; + + // Check Walkability + const isWalkable = this.canMoveTo(x, y); + + // Check if already in path (prevent loops for simplicity or allow backtracking? User said "mark contigua", implying adding) + // If clicking the last added step, maybe remove it? (Undo) + if (this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y) { + // Clicked last step -> Undo + this.plannedPath.pop(); + if (this.onPathChange) this.onPathChange(this.plannedPath); + return; + } + + if (isAdjacent && isWalkable) { + // Check if not already visited in this path to prevent self-intersection weirdness + const alreadyInPath = this.plannedPath.some(p => p.x === x && p.y === y); + const isPlayerPos = this.player.x === x && this.player.y === y; + + if (!alreadyInPath && !isPlayerPos) { + this.plannedPath.push({ x, y }); + if (this.onPathChange) { + this.onPathChange(this.plannedPath); + } } } } + executeMovePath() { + if (!this.player || !this.plannedPath.length) return; + + // Clone path for the move event + const path = [...this.plannedPath]; + + // Update player logic verification immediately (teleport logic) + // The visualization will handle the "botecitos" + const finalDest = path[path.length - 1]; + this.player.x = finalDest.x; + this.player.y = finalDest.y; + + // Trigger Movement Event (Renderer will animate) + if (this.onEntityMove) { + this.onEntityMove(this.player, path); + } + + // Cleanup + this.deselectPlayer(); + } + canMoveTo(x, y) { // Check if cell is walkable (occupied by a tile) return this.dungeon.grid.isOccupied(x, y); } + // Deprecated direct move movePlayer(x, y) { - // Simple direct movement (no pathfinding for now) - const path = [{ x, y }]; - this.player.x = x; this.player.y = y; - - if (this.onEntityMove) { - this.onEntityMove(this.player, path); - } - - // Deselect after move - this.selectedEntity = null; - if (this.onEntitySelect) { - this.onEntitySelect(this.player.id, false); - } - - + if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]); } isPlayerAdjacentToDoor(doorCells) { diff --git a/src/main.js b/src/main.js index ab8ec38..3b30c76 100644 --- a/src/main.js +++ b/src/main.js @@ -87,6 +87,10 @@ generator.onDoorBlocked = (exitData) => { renderer.blockDoor(exitData); }; +game.onPathChange = (path) => { + renderer.updatePathVisualization(path); +}; + // 6. Handle Clicks const handleClick = (x, y, doorMesh) => { // PRIORITY 1: Tile Placement Mode - ignore all clicks @@ -134,7 +138,10 @@ const handleClick = (x, y, doorMesh) => { renderer.setupInteraction( () => cameraManager.getCamera(), handleClick, - () => { } // No right-click + () => { + // Right Click Handler + game.executeMovePath(); + } ); // 7. Start diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index f3ebc51..3ebbadf 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -240,32 +240,47 @@ export class GameRenderer { } if (data.isMoving) { - const duration = 400; // ms per tile + const duration = 300; // Faster jump (300ms) const elapsed = time - data.startTime; - const t = Math.min(elapsed / duration, 1); + const progress = Math.min(elapsed / duration, 1); // Lerp X/Z - mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, t); - mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, t); + mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress); + mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress); - // Jump Arc + // Hop (Botecito) + const jumpHeight = 0.5; const baseHeight = 1.56 / 2; - mesh.position.y = baseHeight + (0.5 * Math.sin(t * Math.PI)); + mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight; - if (t >= 1) { - mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z); + if (progress >= 1) { data.isMoving = false; + mesh.position.y = baseHeight; // Reset height - // IF Finished Sequence (Queue empty) - if (data.pathQueue.length === 0) { - // Check if it's the player (id 'p1') - if (id === 'p1' && this.onHeroFinishedMove) { - // Grid Coords from World Coords (X, -Z) - this.onHeroFinishedMove(mesh.position.x, -mesh.position.z); + // Remove the visualization tile for this step + if (this.pathGroup) { + for (let i = this.pathGroup.children.length - 1; i >= 0; i--) { + const child = this.pathGroup.children[i]; + // Match X and Z (ignoring small float errors) + if (Math.abs(child.position.x - data.targetPos.x) < 0.1 && + Math.abs(child.position.z - data.targetPos.z) < 0.1) { + this.pathGroup.remove(child); + } } } } } + + + + // IF Finished Sequence (Queue empty) + if (data.pathQueue.length === 0) { + // Check if it's the player (id 'p1') + if (id === 'p1' && this.onHeroFinishedMove) { + // Grid Coords from World Coords (X, -Z) + this.onHeroFinishedMove(mesh.position.x, -mesh.position.z); + } + } }); } renderExits(exits) { @@ -585,6 +600,65 @@ export class GameRenderer { return null; } + // ========== PATH VISUALIZATION ========== + + updatePathVisualization(path) { + if (!this.pathGroup) { + this.pathGroup = new THREE.Group(); + this.scene.add(this.pathGroup); + } + + this.pathGroup.clear(); + + if (!path || path.length === 0) return; + + path.forEach((step, index) => { + const geometry = new THREE.PlaneGeometry(0.8, 0.8); + const texture = this.createNumberTexture(index + 1); + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: 0.8, // Texture itself has opacity + side: THREE.DoubleSide + }); + const plane = new THREE.Mesh(geometry, material); + plane.position.set(step.x, 0.02, -step.y); // Slightly above floor + plane.rotation.x = -Math.PI / 2; + + // Store step index to identify it later if needed + plane.userData.stepIndex = index; + + this.pathGroup.add(plane); + }); + } + + createNumberTexture(number) { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + + // Yellow background with 50% opacity + ctx.fillStyle = 'rgba(255, 255, 0, 0.5)'; + ctx.fillRect(0, 0, 64, 64); + + // Border + ctx.strokeStyle = '#EDA900'; + ctx.lineWidth = 4; + ctx.strokeRect(0, 0, 64, 64); + + // Text + ctx.font = 'bold 36px Arial'; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(number.toString(), 32, 32); + + const tex = new THREE.CanvasTexture(canvas); + // tex.magFilter = THREE.NearestFilter; // Optional, might look pixelated + return tex; + } + isPlayerAdjacentToDoor(playerX, playerY, doorMesh) { if (!doorMesh || !doorMesh.userData.isDoor) return false; diff --git a/src/view/UIManager.js b/src/view/UIManager.js index 4a52f64..abbefb3 100644 --- a/src/view/UIManager.js +++ b/src/view/UIManager.js @@ -237,7 +237,7 @@ export class UIManager { if (this.dungeon) { const success = this.dungeon.confirmPlacement(); if (!success) { - alert('❌ No se puede colocar la loseta en esta posición'); + this.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.'); } } };