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.
This commit is contained in:
@@ -9,6 +9,7 @@ export class GameEngine {
|
|||||||
this.player = null;
|
this.player = null;
|
||||||
this.selectedEntity = null;
|
this.selectedEntity = null;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
this.plannedPath = []; // Array of {x,y}
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
this.onEntityUpdate = null;
|
this.onEntityUpdate = null;
|
||||||
@@ -39,54 +40,105 @@ export class GameEngine {
|
|||||||
if (this.onEntityUpdate) {
|
if (this.onEntityUpdate) {
|
||||||
this.onEntityUpdate(this.player);
|
this.onEntityUpdate(this.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCellClick(x, y) {
|
onCellClick(x, y) {
|
||||||
// If no player selected, select player on click
|
// 1. SELECT / DESELECT PLAYER
|
||||||
if (!this.selectedEntity && this.player && x === this.player.x && y === this.player.y) {
|
if (this.player && x === this.player.x && y === this.player.y) {
|
||||||
this.selectedEntity = this.player;
|
if (this.selectedEntity === this.player) {
|
||||||
if (this.onEntitySelect) {
|
// Toggle Deselect
|
||||||
this.onEntitySelect(this.player.id, true);
|
this.deselectPlayer();
|
||||||
|
} else {
|
||||||
|
// Select
|
||||||
|
this.selectedEntity = this.player;
|
||||||
|
if (this.onEntitySelect) {
|
||||||
|
this.onEntitySelect(this.player.id, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If player selected, move to clicked cell
|
// 2. PLAN MOVEMENT (If player selected)
|
||||||
if (this.selectedEntity === this.player) {
|
if (this.selectedEntity === this.player) {
|
||||||
if (this.canMoveTo(x, y)) {
|
this.planStep(x, y);
|
||||||
this.movePlayer(x, y);
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
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) {
|
canMoveTo(x, y) {
|
||||||
// Check if cell is walkable (occupied by a tile)
|
// Check if cell is walkable (occupied by a tile)
|
||||||
return this.dungeon.grid.isOccupied(x, y);
|
return this.dungeon.grid.isOccupied(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated direct move
|
||||||
movePlayer(x, y) {
|
movePlayer(x, y) {
|
||||||
// Simple direct movement (no pathfinding for now)
|
|
||||||
const path = [{ x, y }];
|
|
||||||
|
|
||||||
this.player.x = x;
|
this.player.x = x;
|
||||||
this.player.y = y;
|
this.player.y = y;
|
||||||
|
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
|
||||||
if (this.onEntityMove) {
|
|
||||||
this.onEntityMove(this.player, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deselect after move
|
|
||||||
this.selectedEntity = null;
|
|
||||||
if (this.onEntitySelect) {
|
|
||||||
this.onEntitySelect(this.player.id, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlayerAdjacentToDoor(doorCells) {
|
isPlayerAdjacentToDoor(doorCells) {
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ generator.onDoorBlocked = (exitData) => {
|
|||||||
renderer.blockDoor(exitData);
|
renderer.blockDoor(exitData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
game.onPathChange = (path) => {
|
||||||
|
renderer.updatePathVisualization(path);
|
||||||
|
};
|
||||||
|
|
||||||
// 6. Handle Clicks
|
// 6. Handle Clicks
|
||||||
const handleClick = (x, y, doorMesh) => {
|
const handleClick = (x, y, doorMesh) => {
|
||||||
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
||||||
@@ -134,7 +138,10 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
renderer.setupInteraction(
|
renderer.setupInteraction(
|
||||||
() => cameraManager.getCamera(),
|
() => cameraManager.getCamera(),
|
||||||
handleClick,
|
handleClick,
|
||||||
() => { } // No right-click
|
() => {
|
||||||
|
// Right Click Handler
|
||||||
|
game.executeMovePath();
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. Start
|
// 7. Start
|
||||||
|
|||||||
@@ -240,32 +240,47 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.isMoving) {
|
if (data.isMoving) {
|
||||||
const duration = 400; // ms per tile
|
const duration = 300; // Faster jump (300ms)
|
||||||
const elapsed = time - data.startTime;
|
const elapsed = time - data.startTime;
|
||||||
const t = Math.min(elapsed / duration, 1);
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
// Lerp X/Z
|
// Lerp X/Z
|
||||||
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, 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, t);
|
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;
|
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) {
|
if (progress >= 1) {
|
||||||
mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z);
|
|
||||||
data.isMoving = false;
|
data.isMoving = false;
|
||||||
|
mesh.position.y = baseHeight; // Reset height
|
||||||
|
|
||||||
// IF Finished Sequence (Queue empty)
|
// Remove the visualization tile for this step
|
||||||
if (data.pathQueue.length === 0) {
|
if (this.pathGroup) {
|
||||||
// Check if it's the player (id 'p1')
|
for (let i = this.pathGroup.children.length - 1; i >= 0; i--) {
|
||||||
if (id === 'p1' && this.onHeroFinishedMove) {
|
const child = this.pathGroup.children[i];
|
||||||
// Grid Coords from World Coords (X, -Z)
|
// Match X and Z (ignoring small float errors)
|
||||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
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) {
|
renderExits(exits) {
|
||||||
@@ -585,6 +600,65 @@ export class GameRenderer {
|
|||||||
return null;
|
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) {
|
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
|
||||||
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export class UIManager {
|
|||||||
if (this.dungeon) {
|
if (this.dungeon) {
|
||||||
const success = this.dungeon.confirmPlacement();
|
const success = this.dungeon.confirmPlacement();
|
||||||
if (!success) {
|
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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user