Implement advanced pathfinding and combat visual effects
- Add monster turn visual feedback (green ring on attacker, red ring on victim) - Implement proper attack sequence with timing and animations - Add room boundary and height level pathfinding system - Monsters now respect room walls and can only pass through doors - Add height level support (1-8) with stairs (9) for level transitions - Fix attack validation to prevent attacks through walls - Speed up hero movement animation (300ms per tile) - Fix exploration phase message to not show on initial tile placement - Disable hero movement during exploration phase (doors only)
This commit is contained in:
@@ -3,6 +3,16 @@ export class GridSystem {
|
||||
constructor() {
|
||||
// Map "x,y" -> "tileId"
|
||||
this.occupiedCells = new Map();
|
||||
|
||||
// Map "x,y" -> { tileId: string, height: number (1-9) }
|
||||
this.cellData = new Map();
|
||||
|
||||
// Map "tileId" -> Set of "x,y" strings (all cells belonging to this tile)
|
||||
this.tileCells = new Map();
|
||||
|
||||
// Set of "x,y" strings that are door/exit cells (can cross room boundaries)
|
||||
this.doorCells = new Set();
|
||||
|
||||
this.tiles = [];
|
||||
}
|
||||
|
||||
@@ -48,13 +58,20 @@ export class GridSystem {
|
||||
const rows = layout.length;
|
||||
const anchorX = tileInstance.x;
|
||||
const anchorY = tileInstance.y;
|
||||
const tileId = tileInstance.id;
|
||||
|
||||
// Initialize tile cell set
|
||||
if (!this.tileCells.has(tileId)) {
|
||||
this.tileCells.set(tileId, new Set());
|
||||
}
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const rowData = layout[row];
|
||||
const cols = rowData.length;
|
||||
|
||||
for (let col = 0; col < cols; col++) {
|
||||
if (rowData[col] === 0) continue;
|
||||
const heightValue = rowData[col];
|
||||
if (heightValue === 0) continue;
|
||||
|
||||
const lx = col;
|
||||
const ly = (rows - 1) - row;
|
||||
@@ -63,9 +80,28 @@ export class GridSystem {
|
||||
const gy = anchorY + ly;
|
||||
const key = `${gx},${gy}`;
|
||||
|
||||
this.occupiedCells.set(key, tileInstance.id);
|
||||
// Store basic occupation
|
||||
this.occupiedCells.set(key, tileId);
|
||||
|
||||
// Store detailed cell data (height level)
|
||||
this.cellData.set(key, {
|
||||
tileId: tileId,
|
||||
height: heightValue // 1-8 = levels, 9 = stairs
|
||||
});
|
||||
|
||||
// Add to tile's cell set
|
||||
this.tileCells.get(tileId).add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark exit/door cells
|
||||
if (variant.exits) {
|
||||
variant.exits.forEach(exit => {
|
||||
const exitKey = `${anchorX + exit.x},${anchorY + exit.y}`;
|
||||
this.doorCells.add(exitKey);
|
||||
});
|
||||
}
|
||||
|
||||
this.tiles.push(tileInstance);
|
||||
}
|
||||
|
||||
@@ -102,4 +138,52 @@ export class GridSystem {
|
||||
isOccupied(x, y) {
|
||||
return this.occupiedCells.has(`${x},${y}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell data (tileId, height) for a coordinate
|
||||
*/
|
||||
getCellData(x, y) {
|
||||
return this.cellData.get(`${x},${y}`) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if movement from (x1,y1) to (x2,y2) is valid
|
||||
* considering room boundaries, height levels, and stairs
|
||||
*/
|
||||
canMoveBetween(x1, y1, x2, y2) {
|
||||
const key1 = `${x1},${y1}`;
|
||||
const key2 = `${x2},${y2}`;
|
||||
|
||||
const data1 = this.cellData.get(key1);
|
||||
const data2 = this.cellData.get(key2);
|
||||
|
||||
// Both cells must exist
|
||||
if (!data1 || !data2) return false;
|
||||
|
||||
const sameTile = data1.tileId === data2.tileId;
|
||||
const isDoor1 = this.doorCells.has(key1);
|
||||
const isDoor2 = this.doorCells.has(key2);
|
||||
|
||||
// If different tiles, at least one must be a door
|
||||
if (!sameTile && !isDoor1 && !isDoor2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Height validation
|
||||
const h1 = data1.height;
|
||||
const h2 = data2.height;
|
||||
|
||||
// Stairs (9) can connect to any level
|
||||
if (h1 === 9 || h2 === 9) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same height level is always OK
|
||||
if (h1 === h2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Different heights require stairs - not allowed directly
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export class GameEngine {
|
||||
this.onEntityUpdate = null;
|
||||
this.onEntityMove = null;
|
||||
this.onEntitySelect = null;
|
||||
this.onEntityActive = null; // New: When entity starts/ends turn
|
||||
this.onEntityHit = null; // New: When entity takes damage
|
||||
this.onPathChange = null;
|
||||
}
|
||||
|
||||
@@ -225,9 +227,10 @@ export class GameEngine {
|
||||
if (!this.selectedEntity) return;
|
||||
|
||||
// Valid Phase Check
|
||||
// Allow movement in Hero Phase AND Exploration Phase (for positioning ease)
|
||||
// Allow movement ONLY in Hero Phase.
|
||||
// Exploration Phase is for opening doors only (no movement).
|
||||
const phase = this.turnManager.currentPhase;
|
||||
if (phase !== 'hero' && phase !== 'exploration' && this.selectedEntity.type === 'hero') {
|
||||
if (phase !== 'hero' && this.selectedEntity.type === 'hero') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,12 +34,18 @@ export class MonsterAI {
|
||||
|
||||
processMonster(monster) {
|
||||
return new Promise(resolve => {
|
||||
// NO green ring here - only during attack
|
||||
|
||||
// Calculate delay based on potential move distance to ensure animation finishes
|
||||
const moveTime = (monster.stats.move * 300) + 500; // +buffer for attack logic
|
||||
// SLOWER: 600ms per tile + Extra buffer for potential attack sequence
|
||||
const moveTime = (monster.stats.move * 600) + 3000; // 3s buffer for attack sequence
|
||||
|
||||
setTimeout(() => {
|
||||
this.actMonster(monster);
|
||||
setTimeout(resolve, moveTime);
|
||||
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, moveTime);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
@@ -88,11 +94,15 @@ export class MonsterAI {
|
||||
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
|
||||
|
||||
// 7. Check if NOW adjacent after move -> ATTACK
|
||||
const postMoveHero = this.getAdjacentHero(monster);
|
||||
if (postMoveHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
|
||||
this.performAttack(monster, postMoveHero);
|
||||
}
|
||||
// Wait for movement animation to complete before checking
|
||||
const movementDuration = actualPath.length * 600;
|
||||
setTimeout(() => {
|
||||
const postMoveHero = this.getAdjacentHero(monster);
|
||||
if (postMoveHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
|
||||
this.performAttack(monster, postMoveHero);
|
||||
}
|
||||
}, movementDuration);
|
||||
}
|
||||
|
||||
getClosestHero(monster) {
|
||||
@@ -119,15 +129,28 @@ export class MonsterAI {
|
||||
});
|
||||
}
|
||||
|
||||
isOccupied(x, y) {
|
||||
// Check Grid
|
||||
if (!this.game.dungeon.grid.isOccupied(x, y)) return true; // Wall/Void
|
||||
isOccupied(x, y, fromX, fromY) {
|
||||
// Check if target cell exists in grid
|
||||
if (!this.game.dungeon.grid.isOccupied(x, y)) {
|
||||
return true; // Wall/Void
|
||||
}
|
||||
|
||||
// Check Heroes
|
||||
if (this.game.heroes.some(h => h.x === x && h.y === y)) return true;
|
||||
if (this.game.heroes.some(h => h.x === x && h.y === y)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check Monsters
|
||||
if (this.game.monsters.some(m => m.x === x && m.y === y)) return true;
|
||||
if (this.game.monsters.some(m => m.x === x && m.y === y)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NEW: Check if movement is valid (room boundaries, height levels, stairs)
|
||||
if (fromX !== undefined && fromY !== undefined) {
|
||||
if (!this.game.dungeon.grid.canMoveBetween(fromX, fromY, x, y)) {
|
||||
return true; // Movement blocked by room boundary or height restriction
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -168,13 +191,9 @@ export class MonsterAI {
|
||||
];
|
||||
|
||||
for (const n of neighbors) {
|
||||
// Determine blocking
|
||||
// Note: We normally block if occupied.
|
||||
// But for Fallback to work in a crowd, we might want to know if we can at least get closer.
|
||||
// However, we literally cannot walk on occupied tiles.
|
||||
// So the fallback is simply: "To the tile closest to the hero that ISN'T occupied."
|
||||
|
||||
if (this.isOccupied(n.x, n.y)) continue;
|
||||
// Check if movement from current to neighbor is valid
|
||||
// This now includes room boundary, height, and stair checks
|
||||
if (this.isOccupied(n.x, n.y, current.x, current.y)) continue;
|
||||
|
||||
const key = `${n.x},${n.y}`;
|
||||
if (!visited.has(key)) {
|
||||
@@ -194,13 +213,41 @@ export class MonsterAI {
|
||||
}
|
||||
|
||||
performAttack(monster, hero) {
|
||||
const result = CombatMechanics.resolveMeleeAttack(monster, hero);
|
||||
console.log(`[COMBAT] ${result.message}`);
|
||||
// SEQUENCE:
|
||||
// 1. Show green ring on monster
|
||||
// 2. Monster attack animation (we'll simulate with delay)
|
||||
// 3. Show red ring + shake on hero
|
||||
// 4. Remove both rings
|
||||
// 5. Show combat result
|
||||
|
||||
// Notify UI/GameEngine about damage (if we had a hook)
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
const result = CombatMechanics.resolveMeleeAttack(monster, hero);
|
||||
|
||||
// Step 1: Green ring on attacker
|
||||
if (this.game.onEntityActive) {
|
||||
this.game.onEntityActive(monster.id, true);
|
||||
}
|
||||
|
||||
// Step 2: Attack animation delay (500ms)
|
||||
setTimeout(() => {
|
||||
// Step 3: Trigger hit visual on defender (if hit succeeded)
|
||||
if (result.hitSuccess && this.game.onEntityHit) {
|
||||
this.game.onEntityHit(hero.id);
|
||||
}
|
||||
|
||||
// Step 4: Remove green ring after red ring appears (1200ms for red ring duration)
|
||||
setTimeout(() => {
|
||||
if (this.game.onEntityActive) {
|
||||
this.game.onEntityActive(monster.id, false);
|
||||
}
|
||||
|
||||
// Step 5: Show combat result after both rings are gone
|
||||
setTimeout(() => {
|
||||
if (this.game.onCombatResult) {
|
||||
this.game.onCombatResult(result);
|
||||
}
|
||||
}, 200); // Small delay after rings disappear
|
||||
}, 1200); // Wait for red ring to disappear
|
||||
}, 500); // Attack animation delay
|
||||
}
|
||||
|
||||
getAdjacentHero(entity) {
|
||||
@@ -215,8 +262,17 @@ export class MonsterAI {
|
||||
|
||||
const dx = Math.abs(entity.x - hero.x);
|
||||
const dy = Math.abs(entity.y - hero.y);
|
||||
// Orthogonal adjacency only (Manhattan dist 1)
|
||||
return (dx + dy) === 1;
|
||||
|
||||
// Must be orthogonally adjacent (Manhattan dist 1)
|
||||
if ((dx + dy) !== 1) return false;
|
||||
|
||||
// NEW: Check if movement between monster and hero is valid
|
||||
// This prevents attacking through walls/room boundaries
|
||||
if (!this.game.dungeon.grid.canMoveBetween(entity.x, entity.y, hero.x, hero.y)) {
|
||||
return false; // Wall or room boundary blocks attack
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
13
src/main.js
13
src/main.js
@@ -37,6 +37,11 @@ generator.grid.placeTile = (instance, variant, card) => {
|
||||
setTimeout(() => {
|
||||
renderer.renderExits(generator.availableExits);
|
||||
|
||||
// Don't show modal if we are not in Exploration phase (e.g. during Setup)
|
||||
if (game.turnManager.currentPhase !== 'exploration') {
|
||||
return;
|
||||
}
|
||||
|
||||
// NEW RULE: Exploration ends turn immediately. No monsters yet.
|
||||
// Monsters appear when a hero ENTERS the new room in the next turn.
|
||||
ui.showModal('Exploración Completada',
|
||||
@@ -98,6 +103,14 @@ game.onEntityMove = (entity, path) => {
|
||||
renderer.moveEntityAlongPath(entity, path);
|
||||
};
|
||||
|
||||
game.onEntityActive = (entityId, isActive) => {
|
||||
renderer.setEntityActive(entityId, isActive);
|
||||
};
|
||||
|
||||
game.onEntityHit = (entityId) => {
|
||||
renderer.triggerDamageEffect(entityId);
|
||||
};
|
||||
|
||||
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
|
||||
|
||||
renderer.onHeroFinishedMove = (x, y) => {
|
||||
|
||||
@@ -217,6 +217,79 @@ export class GameRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
setEntityActive(entityId, isActive) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
|
||||
// Remove existing active ring if any
|
||||
const oldRing = mesh.getObjectByName("ActiveRing");
|
||||
if (oldRing) mesh.remove(oldRing);
|
||||
|
||||
if (isActive) {
|
||||
// Phosphorescent Green Ring - MATCHING SIZE (0.3 - 0.4)
|
||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
|
||||
// Basic Material does not support emissive. Use color + opacity for "glow" feel.
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00, // Green
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Align with floor (relative to mesh center)
|
||||
const h = 1.56;
|
||||
ring.position.y = -h / 2 + 0.05;
|
||||
|
||||
ring.name = "ActiveRing";
|
||||
mesh.add(ring);
|
||||
}
|
||||
}
|
||||
|
||||
triggerDamageEffect(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
|
||||
// 1. Red Halo (Temporary) - MATCHING ATTACKER SIZE (0.3 - 0.4)
|
||||
const hitRingGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const hitRingMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff0000,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.9
|
||||
});
|
||||
const hitRing = new THREE.Mesh(hitRingGeom, hitRingMat);
|
||||
hitRing.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Align with floor
|
||||
const h = 1.56;
|
||||
hitRing.position.y = -h / 2 + 0.05;
|
||||
|
||||
hitRing.name = "HitRing";
|
||||
|
||||
mesh.add(hitRing);
|
||||
|
||||
// Remove Red Halo after 1200ms (matching the timing in MonsterAI)
|
||||
setTimeout(() => {
|
||||
if (mesh && hitRing) mesh.remove(hitRing);
|
||||
}, 1200);
|
||||
|
||||
// 2. Shake Animation (800ms)
|
||||
const originalPos = mesh.position.clone();
|
||||
const startTime = performance.now();
|
||||
const duration = 800; // ms
|
||||
|
||||
mesh.userData.shake = {
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
magnitude: 0.1,
|
||||
originalPos: originalPos
|
||||
};
|
||||
}
|
||||
|
||||
moveEntityAlongPath(entity, path) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
@@ -249,7 +322,7 @@ export class GameRenderer {
|
||||
}
|
||||
|
||||
if (data.isMoving) {
|
||||
const duration = 300; // Faster jump (300ms)
|
||||
const duration = 300; // Hero movement speed (300ms per tile)
|
||||
const elapsed = time - data.startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
@@ -278,6 +351,25 @@ export class GameRenderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (data.shake) {
|
||||
// HANDLE SHAKE
|
||||
const elapsed = time - data.shake.startTime;
|
||||
if (elapsed < data.shake.duration) {
|
||||
const progress = elapsed / data.shake.duration;
|
||||
// Dampen over time
|
||||
const mag = data.shake.magnitude * (1 - progress);
|
||||
|
||||
// Random jitter
|
||||
const offsetX = (Math.random() - 0.5) * mag * 2;
|
||||
const offsetZ = (Math.random() - 0.5) * mag * 2;
|
||||
|
||||
mesh.position.x = data.shake.originalPos.x + offsetX;
|
||||
mesh.position.z = data.shake.originalPos.z + offsetZ;
|
||||
} else {
|
||||
// Reset
|
||||
mesh.position.copy(data.shake.originalPos);
|
||||
delete data.shake;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user