Fix tile rendering dimensions and alignment, update tile definitions to use height

This commit is contained in:
2026-01-02 23:06:40 +01:00
parent 9234a2e3a0
commit 970ff224c3
17 changed files with 2322 additions and 869 deletions

View File

@@ -1,85 +1,68 @@
import { TILES } from './TileDefinitions.js';
export class DungeonDeck {
constructor() {
this.cards = [];
this.discards = [];
// We don't initialize automatically anymore
}
/**
* Constructs the deck according to the specific Warhammer Quest rules.
* Rulebook steps:
* 1. Take 6 random Dungeon Cards (Bottom pool).
* 2. Add Objective Room card to Bottom pool.
* 3. Shuffle Bottom pool (7 cards).
* 4. Take 6 random Dungeon Cards (Top pool).
* 5. Stack Top pool on Bottom pool.
* Total: 13 cards.
*
* @param {string} objectiveTileId - ID of the objective/exit room.
*/
generateMissionDeck(objectiveTileId) {
console.log("🔍 Inspecting TILES object keys:", Object.keys(TILES));
this.cards = [];
// 1. Create a "Pool" of standard dungeon tiles (Rooms & Corridors)
// We replicate the physical deck distribution first
// 1. Create a "Pool" of standard dungeon tiles
let pool = [];
const composition = [
{ id: 'room_dungeon', count: 6 },
// Objective room is special, handled separately
{ id: 'corridor_straight', count: 7 },
{ id: 'corridor_steps', count: 1 },
{ id: 'corridor_corner', count: 1 },
{ id: 'corridor_corner', count: 1 }, // L-Shape
{ id: 'junction_t', count: 3 }
];
composition.forEach(item => {
const tileDef = TILES.find(t => t.id === item.id);
// FIXED: Access by Key string directly
const tileDef = TILES[item.id];
if (tileDef) {
for (let i = 0; i < item.count; i++) {
pool.push(tileDef);
}
} else {
console.error(`❌ Missing Tile Definition for ID: ${item.id}`);
}
});
// Helper to pull random cards
const drawRandom = (source, count) => {
const drawn = [];
for (let i = 0; i < count; i++) {
if (source.length === 0) break;
const idx = Math.floor(Math.random() * source.length);
drawn.push(source[idx]);
source.splice(idx, 1); // Remove from pool
source.splice(idx, 1);
}
return drawn;
};
// --- Step 1 & 2: Bottom Pool (6 Random + Objective) ---
// --- Step 1 & 2: Bottom Pool ---
const bottomPool = drawRandom(pool, 6);
// Add Objective Card
const objectiveDef = TILES.find(t => t.id === objectiveTileId);
const objectiveDef = TILES[objectiveTileId];
if (objectiveDef) {
bottomPool.push(objectiveDef);
} else {
console.error("Objective Tile ID not found:", objectiveTileId);
// Fallback: Add a generic room if objective missing?
}
// --- Step 3: Shuffle Bottom Pool ---
this.shuffleArray(bottomPool);
// --- Step 4: Top Pool (6 Random) ---
// --- Step 4: Top Pool ---
const topPool = drawRandom(pool, 6);
// Note: No shuffle explicitly needed for Top Pool if drawn randomly,
// but shuffling ensures random order of the 6 drawn.
this.shuffleArray(topPool);
// --- Step 5: Stack (Top on Bottom) ---
// Array[0] is the "Top" card (first to be drawn)
// --- Step 5: Stack ---
this.cards = [...topPool, ...bottomPool];
console.log(`Deck Generated: ${this.cards.length} cards.`);
@@ -93,15 +76,12 @@ export class DungeonDeck {
}
draw() {
if (this.cards.length === 0) {
return null; // Deck empty
}
return this.cards.shift(); // Take from top
if (this.cards.length === 0) return null;
return this.cards.shift();
}
// Useful for Campaign logic: Insert a specific card at position
insertCard(tileId, position = 0) {
const tileDef = TILES.find(t => t.id === tileId);
const tileDef = TILES[tileId];
if (tileDef) {
this.cards.splice(position, 0, tileDef);
}

View File

@@ -1,296 +1,317 @@
import { DIRECTIONS } from './Constants.js';
import { GridSystem } from './GridSystem.js';
import { DungeonDeck } from './DungeonDeck.js';
import { TILES } from './TileDefinitions.js';
const PLACEMENT_STATE = {
WAITING_DOOR: 'WAITING_DOOR',
PLACING_TILE: 'PLACING_TILE',
COMPLETE: 'COMPLETE'
};
export class DungeonGenerator {
constructor() {
this.grid = new GridSystem();
this.deck = new DungeonDeck();
this.pendingExits = []; // Array of global {x, y, direction}
this.placedTiles = [];
this.isComplete = false;
this.availableExits = []; // Exits where player can choose to expand
// Placement State
this.state = PLACEMENT_STATE.WAITING_DOOR;
this.currentCard = null;
this.placementRotation = DIRECTIONS.NORTH;
this.placementX = 0;
this.placementY = 0;
this.selectedExit = null;
// Callbacks for UI
this.onStateChange = null;
this.onPlacementUpdate = null;
}
startDungeon(missionConfig) {
// 1. Prepare Deck (Rulebook: 13 cards, 6+1+6)
// We need an objective tile ID from the config
const objectiveId = missionConfig.type === 'quest' ? 'room_objective' : 'room_dungeon'; // Fallback for now
const objectiveId = missionConfig?.type === 'quest' ? 'room_objective' : 'room_dungeon';
this.deck.generateMissionDeck(objectiveId);
// 2. Rulebook Step 4: "Flip the first card. This is the entrance."
const startCard = this.deck.draw();
if (!startCard) {
console.error("Deck is empty on start!");
// 1. Draw and place first card automatically at origin
const firstCard = this.deck.draw();
if (!firstCard) {
console.error("❌ Empty deck");
return;
}
// 3. Place the Entry Tile at (0,0)
// We assume rotation NORTH by default for the first piece
const startInstance = {
id: `tile_0_${startCard.id}`,
defId: startCard.id,
x: 0,
y: 0,
rotation: DIRECTIONS.NORTH
};
this.placeCardFinal(firstCard, 0, 0, DIRECTIONS.NORTH);
console.log(`🏰 Dungeon started with ${firstCard.name}`);
if (this.grid.canPlace(startCard, 0, 0, DIRECTIONS.NORTH)) {
this.grid.placeTile(startInstance, startCard);
this.placedTiles.push(startInstance);
this.addExitsToQueue(startInstance, startCard);
console.log(`Dungeon started with ${startCard.name}`);
} else {
console.error("Failed to place starting tile (Grid collision at 0,0?)");
}
// 2. Transition to door selection
this.state = PLACEMENT_STATE.WAITING_DOOR;
this.notifyStateChange();
}
step() {
if (this.isComplete) return false;
if (this.pendingExits.length === 0) {
console.log("No more exits available. Dungeon generation stopped.");
this.isComplete = true;
/**
* Player selects a door to expand from
*/
selectDoor(exitPoint) {
console.log('[DungeonGenerator] selectDoor called with:', exitPoint);
console.log('[DungeonGenerator] Current state:', this.state);
console.log('[DungeonGenerator] Available exits:', this.availableExits);
if (this.state !== PLACEMENT_STATE.WAITING_DOOR) {
console.warn("Not in door selection mode");
return false;
}
const card = this.deck.draw();
if (!card) {
console.log("Deck empty. Dungeon complete.");
this.isComplete = true;
if (!exitPoint) {
console.error("exitPoint is undefined!");
return false;
}
// We process exits in groups now?
// Or simply: When we pick an exit, we verify if it is part of a larger door.
// Actually, 'pendingExits' contains individual cells.
// Let's pick one.
const targetExit = this.pendingExits.shift();
// Validate exit exists
const exitExists = this.availableExits.some(
e => e.x === exitPoint.x && e.y === exitPoint.y && e.direction === exitPoint.direction
);
// 1. Identify the "Global Reference Point" for the door this exit belongs to.
// (If door is 2-wide, we want the One with the LOWEST X or LOWEST Y).
// WE MUST FIND ITS SIBLING if it exists in 'pendingExits'.
// This stops us from trying to attach a door twice (once per cell).
// Simple heuristic: If we have an exit at (x,y), check (x+1,y) or (x,y+1) depending on dir.
// If the sibling is also in pendingExits, we effectively "consume" it too for this placement.
// Better: Find the "Left-Most" or "Bottom-Most" cell of this specific connection interface.
// And use THAT as the target.
const targetRef = this.findExitReference(targetExit);
console.log(`Attempting to place ${card.name} at Global Ref ${targetRef.x},${targetRef.y} (${targetRef.direction})`);
const requiredInputDirection = this.getOppositeDirection(targetRef.direction);
let placed = false;
// Try to fit the card
// We iterate input exits on the NEW card.
// We only look at "Reference" exits on the new card too (min x/y) to avoid duplicate attempts.
const candidateExits = this.UniqueExits(card);
for (const candidateExit of candidateExits) {
const rotation = this.calculateRequiredRotation(candidateExit.direction, requiredInputDirection);
// Now calculate ALIGNMENT.
// We want the "Min Cell" of the Candidate Door (after rotation)
// To overlap with the "Neighbor Cell" of the "Min Cell" of the Target Door?
// NO.
// Target Door Min Cell is at (TX, TY).
// Its "Connection Neighbor" is at (NX, NY).
// We want Candidate Door (Rotated) Min Cell to be at (NX, NY).
// 1. Calculate the offset of Candidate 'Min Cell' relative to Tile Origin (0,0) AFTER rotation.
const rotatedOffset = this.getRotatedOffset(candidateExit, rotation);
// 2. Calculate the global connection point input
const connectionPoint = this.getNeighborCell(targetRef.x, targetRef.y, targetRef.direction);
// 3. Tile Position
const posX = connectionPoint.x - rotatedOffset.x;
const posY = connectionPoint.y - rotatedOffset.y;
if (this.grid.canPlace(card, posX, posY, rotation)) {
// Success
const newInstance = {
id: `tile_${this.placedTiles.length}_${card.id}`,
defId: card.id,
x: posX,
y: posY,
rotation: rotation
};
this.grid.placeTile(newInstance, card);
this.placedTiles.push(newInstance);
// Add NEW exits
this.addExitsToQueue(newInstance, card);
// Cleanup: Remove the used exit(s) from pendingExits
// We used targetRef. We must also remove its sibling if it exists.
// Or simply: filter out any pending exit that is now blocked.
this.cleanupPendingExits();
placed = true;
break;
}
if (!exitExists) {
console.warn("Invalid exit selected");
return false;
}
if (!placed) {
console.log(`Could not fit ${card.name}. Discarding.`);
// If failed, return the exit to the pool?
// Or discard the exit as "Dead End"?
// For now, put it back at the end of queue.
this.pendingExits.push(targetExit);
this.selectedExit = exitPoint;
// Draw next card
this.currentCard = this.deck.draw();
if (!this.currentCard) {
console.log("Deck empty - dungeon complete");
this.state = PLACEMENT_STATE.COMPLETE;
this.notifyStateChange();
return false;
}
// Calculate initial placement position (3 units above the connection point)
const connectionPoint = this.neighbor(exitPoint.x, exitPoint.y, exitPoint.direction);
// Start with NORTH rotation
this.placementRotation = DIRECTIONS.NORTH;
const variant = this.currentCard.variants[this.placementRotation];
// Find the exit on the new tile that should connect to selectedExit
const requiredDirection = this.opposite(exitPoint.direction);
const matchingExits = variant.exits.filter(e => e.direction === requiredDirection);
if (matchingExits.length > 0) {
// Use first matching exit as anchor
const anchor = matchingExits[0];
this.placementX = Math.round(connectionPoint.x - anchor.x);
this.placementY = Math.round(connectionPoint.y - anchor.y);
} else {
// Fallback: center on connection point
this.placementX = Math.round(connectionPoint.x);
this.placementY = Math.round(connectionPoint.y);
}
this.state = PLACEMENT_STATE.PLACING_TILE;
this.notifyPlacementUpdate();
this.notifyStateChange();
console.log(`📦 Placing ${this.currentCard.name} at (${this.placementX}, ${this.placementY})`);
return true;
}
// --- Helpers ---
getNeighborCell(x, y, dir) {
switch (dir) {
case DIRECTIONS.NORTH: return { x: x, y: y + 1 };
case DIRECTIONS.SOUTH: return { x: x, y: y - 1 };
case DIRECTIONS.EAST: return { x: x + 1, y: y };
case DIRECTIONS.WEST: return { x: x - 1, y: y };
}
/**
* Rotate placement tile 90° clockwise
*/
rotatePlacement() {
if (this.state !== PLACEMENT_STATE.PLACING_TILE) return;
const rotations = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const currentIndex = rotations.indexOf(this.placementRotation);
this.placementRotation = rotations[(currentIndex + 1) % 4];
console.log(`🔄 Rotated to ${this.placementRotation}`);
this.notifyPlacementUpdate();
}
findExitReference(exit) {
// If facing North/South, Reference is Minimum X.
// If facing East/West, Reference is Minimum Y.
/**
* Move placement tile by offset
*/
movePlacement(dx, dy) {
if (this.state !== PLACEMENT_STATE.PLACING_TILE) return;
// This function assumes 'exit' is from pendingExits (Global coords).
// It checks if there is a "Lower" sibling also in pendingExits.
// If so, returns the lower sibling. BEFORE using this exit.
this.placementX += dx;
this.placementY += dy;
let bestExit = exit;
// Check for siblings in pendingExits that match direction and are < coordinate
// This is O(N) but N is small.
for (const other of this.pendingExits) {
if (other === exit) continue;
if (other.direction !== exit.direction) continue;
if (exit.direction === DIRECTIONS.NORTH || exit.direction === DIRECTIONS.SOUTH) {
// Check X. Adjacent implies y same, x diff 1.
if (other.y === exit.y && Math.abs(other.x - exit.x) === 1) {
if (other.x < bestExit.x) bestExit = other;
}
} else {
// Check Y. adjacent implies x same, y diff 1.
if (other.x === exit.x && Math.abs(other.y - exit.y) === 1) {
if (other.y < bestExit.y) bestExit = other;
}
}
}
return bestExit;
console.log(`↔️ Moved to (${this.placementX}, ${this.placementY})`);
this.notifyPlacementUpdate();
}
UniqueExits(tileDef) {
// Filter tileDef.exits to only return the "Reference" (Min x/y) for each face/group.
// This prevents trying to attach the same door 2 times.
const unique = [];
const seen = new Set(); // store "dir_coord" keys
/**
* Check if current placement is valid
*/
isPlacementValid() {
if (!this.currentCard || this.state !== PLACEMENT_STATE.PLACING_TILE) return false;
// Sort exits to ensure we find Min first
const sorted = [...tileDef.exits].sort((a, b) => {
if (a.direction !== b.direction) return a.direction.localeCompare(b.direction);
if (a.x !== b.x) return a.x - b.x;
return a.y - b.y;
});
for (const ex of sorted) {
// Identifier for the "Door Group".
// If North/South: ID is "Dir_Y". (X varies)
// If East/West: ID is "Dir_X". (Y varies)
// Actually, we just need to pick the first one we see (since we sorted by X then Y).
// If we have (0,0) and (1,0) for SOUTH. Sorted -> (0,0) comes first.
// We take (0,0). We assume (1,0) is part of same door.
// Heuristic: If this exit is adjacent to the last added unique exit of same direction, skip it.
const last = unique[unique.length - 1];
let isSameDoor = false;
if (last && last.direction === ex.direction) {
if (ex.direction === DIRECTIONS.NORTH || ex.direction === DIRECTIONS.SOUTH) {
// Vertical door, check horizontal adjacency
if (last.y === ex.y && Math.abs(last.x - ex.x) <= 1) isSameDoor = true;
} else {
// Horizontal door, check vertical adjacency
if (last.x === ex.x && Math.abs(last.y - ex.y) <= 1) isSameDoor = true;
}
}
if (!isSameDoor) {
unique.push(ex);
}
}
return unique;
const variant = this.currentCard.variants[this.placementRotation];
return this.grid.canPlace(variant, this.placementX, this.placementY);
}
getRotatedOffset(localExit, rotation) {
// Calculate where the 'localExit' ends up relative to (0,0) after rotation.
// localExit is the "Reference" (Min) of the candidate door.
let rx, ry;
const lx = localExit.x;
const ly = localExit.y;
switch (rotation) {
case DIRECTIONS.NORTH: rx = lx; ry = ly; break;
case DIRECTIONS.SOUTH: rx = -lx; ry = -ly; break;
case DIRECTIONS.EAST: rx = ly; ry = -lx; break;
case DIRECTIONS.WEST: rx = -ly; ry = lx; break;
/**
* Confirm and finalize tile placement
*/
confirmPlacement() {
if (this.state !== PLACEMENT_STATE.PLACING_TILE) {
console.warn("Not in placement mode");
return false;
}
return { x: rx, y: ry };
}
getOppositeDirection(dir) {
switch (dir) {
case DIRECTIONS.NORTH: return DIRECTIONS.SOUTH;
case DIRECTIONS.SOUTH: return DIRECTIONS.NORTH;
case DIRECTIONS.EAST: return DIRECTIONS.WEST;
case DIRECTIONS.WEST: return DIRECTIONS.EAST;
if (!this.isPlacementValid()) {
console.warn("❌ Cannot place tile - invalid position");
return false;
}
console.log(`[confirmPlacement] Placing at (${this.placementX}, ${this.placementY}) rotation: ${this.placementRotation}`);
// Round to integers (tiles must be on grid cells)
const finalX = Math.round(this.placementX);
const finalY = Math.round(this.placementY);
console.log(`[confirmPlacement] Rounded to (${finalX}, ${finalY})`);
// Place the tile
this.placeCardFinal(
this.currentCard,
finalX,
finalY,
this.placementRotation
);
// Reset placement state
this.currentCard = null;
this.selectedExit = null;
this.state = PLACEMENT_STATE.WAITING_DOOR;
this.notifyPlacementUpdate(); // Clear preview
this.notifyStateChange();
console.log("✅ Tile placed successfully");
return true;
}
calculateRequiredRotation(localDir, targetGlobalDir) {
const dirs = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const localIdx = dirs.indexOf(localDir);
const targetIdx = dirs.indexOf(targetGlobalDir);
const diff = (targetIdx - localIdx + 4) % 4;
return dirs[diff];
/**
* Internal: Actually place a card on the grid
*/
placeCardFinal(card, x, y, rotation) {
console.log('[placeCardFinal] Card:', card);
console.log('[placeCardFinal] Card.variants:', card.variants);
console.log('[placeCardFinal] Rotation:', rotation, 'Type:', typeof rotation);
const variant = card.variants[rotation];
console.log('[placeCardFinal] Variant:', variant);
const instance = {
id: `tile_${this.placedTiles.length}`,
defId: card.id,
x, y, rotation,
name: card.name
};
this.grid.placeTile(instance, variant, card);
this.placedTiles.push(instance);
// Update available exits
this.updateAvailableExits(instance, variant, x, y);
}
addExitsToQueue(tileInstance, tileDef) {
for (const exit of tileDef.exits) {
const globalPoint = this.grid.getGlobalPoint(exit.x, exit.y, tileInstance);
const globalDir = this.grid.getRotatedDirection(exit.direction, tileInstance.rotation);
/**
* Update list of exits player can choose from
*/
updateAvailableExits(instance, variant, anchorX, anchorY) {
console.log('[updateAvailableExits] ===== NUEVO CODIGO ===== Called for tile:', instance.id);
console.log('[updateAvailableExits] Variant exits:', variant.exits);
console.log('[updateAvailableExits] Anchor:', anchorX, anchorY);
// Check if blocked immediately
const neighbor = this.getNeighborCell(globalPoint.x, globalPoint.y, globalDir);
const key = `${neighbor.x},${neighbor.y}`;
// Add new exits from this tile
for (const ex of variant.exits) {
const gx = anchorX + ex.x;
const gy = anchorY + ex.y;
if (!this.grid.occupiedCells.has(key)) {
this.pendingExits.push({
x: globalPoint.x,
y: globalPoint.y,
direction: globalDir
const leadingTo = this.neighbor(gx, gy, ex.direction);
const isOccupied = this.grid.isOccupied(leadingTo.x, leadingTo.y);
console.log(`[updateAvailableExits] Exit at (${gx}, ${gy}) dir ${ex.direction} -> leads to (${leadingTo.x}, ${leadingTo.y}) occupied: ${isOccupied}`);
if (!isOccupied) {
this.availableExits.push({
x: gx,
y: gy,
direction: ex.direction,
tileId: instance.id
});
console.log('[updateAvailableExits] ✓ Added exit');
}
}
console.log('[updateAvailableExits] Total available exits now:', this.availableExits.length);
// Remove exits that are now blocked or connected
this.availableExits = this.availableExits.filter(exit => {
const leadingTo = this.neighbor(exit.x, exit.y, exit.direction);
return !this.grid.isOccupied(leadingTo.x, leadingTo.y);
});
}
/**
* Get current placement preview data for renderer
*/
getPlacementPreview() {
if (this.state !== PLACEMENT_STATE.PLACING_TILE || !this.currentCard) {
return null;
}
const variant = this.currentCard.variants[this.placementRotation];
const cells = this.grid.calculateCells(variant, this.placementX, this.placementY);
const isValid = this.isPlacementValid();
return {
card: this.currentCard,
rotation: this.placementRotation,
x: this.placementX,
y: this.placementY,
cells,
isValid,
variant
};
}
// --- Helpers ---
notifyStateChange() {
if (this.onStateChange) {
this.onStateChange(this.state);
}
}
cleanupPendingExits() {
// Remove exits that now point to occupied cells (blocked by newly placed tile)
this.pendingExits = this.pendingExits.filter(ex => {
const neighbor = this.getNeighborCell(ex.x, ex.y, ex.direction);
const key = `${neighbor.x},${neighbor.y}`;
return !this.grid.occupiedCells.has(key);
});
notifyPlacementUpdate() {
if (this.onPlacementUpdate) {
const preview = this.getPlacementPreview();
this.onPlacementUpdate(preview);
}
}
neighbor(x, y, dir) {
switch (dir) {
case DIRECTIONS.NORTH: return { x, y: y + 1 };
case DIRECTIONS.SOUTH: return { x, y: y - 1 };
case DIRECTIONS.EAST: return { x: x + 1, y };
case DIRECTIONS.WEST: return { x: x - 1, y };
}
}
opposite(dir) {
const map = {
[DIRECTIONS.NORTH]: DIRECTIONS.SOUTH,
[DIRECTIONS.SOUTH]: DIRECTIONS.NORTH,
[DIRECTIONS.EAST]: DIRECTIONS.WEST,
[DIRECTIONS.WEST]: DIRECTIONS.EAST
};
return map[dir];
}
}

View File

@@ -0,0 +1,50 @@
tryPlaceCard(card, targetExit) {
const requiredDirection = this.opposite(targetExit.direction);
const targetCell = this.neighbor(targetExit.x, targetExit.y, targetExit.direction);
const rotations = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
// const shuffled = this.shuffle(rotations);
// 🔧 DEBUG: Force SOUTH rotation only
const forcedRotations = [DIRECTIONS.SOUTH];
console.log('⚠️ FORCED ROTATION TO SOUTH ONLY FOR TESTING');
let bestPlacement = null;
let maxConnections = -1;
for (const rotation of forcedRotations) {
const rotatedExits = this.rotateExits(card.exits, rotation);
const candidates = rotatedExits.filter(e => e.direction === requiredDirection);
for (const candidate of candidates) {
const anchorX = targetCell.x - candidate.x;
const anchorY = targetCell.y - candidate.y;
if (this.grid.canPlace(card, anchorX, anchorY, rotation)) {
let score = 0;
for (const exit of rotatedExits) {
const globalX = anchorX + exit.x;
const globalY = anchorY + exit.y;
const neighbor = this.neighbor(globalX, globalY, exit.direction);
if (this.grid.occupiedCells.has(`${neighbor.x},${neighbor.y}`)) {
score++;
}
}
if (score > maxConnections) {
maxConnections = score;
bestPlacement = { card, anchorX, anchorY, rotation };
}
}
}
}
if (bestPlacement) {
this.placeTileAt(bestPlacement.card, bestPlacement.anchorX, bestPlacement.anchorY, bestPlacement.rotation);
console.log(`✅ Best Placement Selected: Score ${maxConnections}`);
return true;
}
return false;
}

View File

@@ -1,184 +1,106 @@
import { DIRECTIONS } from './Constants.js';
export class GridSystem {
/**
* The GridSystem maintains the "Source of Truth" for the dungeon layout.
* It knows which cells are occupied and by whom.
* Dependencies: Constants.js (DIRECTIONS)
*/
constructor() {
// We use a Map for O(1) lookups.
// Key: "x,y" (String) -> Value: "tileId" (String)
// Map "x,y" -> "tileId"
this.occupiedCells = new Map();
// We also keep a list of placed tile objects for easier iteration if needed later.
this.tiles = [];
}
/**
* Checks if a tile can be placed at the given coordinates with the given rotation.
* Needs: The Tile Definition (to know size), the target X,Y, and desired Rotation.
* Checks if a specific VARIANT can be placed at anchorX, anchorY.
* Does NOT rotate anything. Assumes variant is already the correct shape.
*/
canPlace(tileDef, startX, startY, rotation) {
// 1. Calculate the real-world coordinates of every single cell this tile would occupy.
const cells = this.getGlobalCells(tileDef, startX, startY, rotation);
canPlace(variant, anchorX, anchorY) {
const layout = variant.layout;
const rows = layout.length;
// 2. Check each cell against our Map of occupied spots.
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
if (this.occupiedCells.has(key)) {
return false; // COLLISION! Spot already taken.
for (let row = 0; row < rows; row++) {
const rowData = layout[row];
const cols = rowData.length;
for (let col = 0; col < cols; col++) {
// If cell is 0 (empty), skip
if (rowData[col] === 0) continue;
// Calculate Global Position
// Matrix Row 0 is Top (Max Y). Matrix Row Max is Bottom (Y=0).
const lx = col;
const ly = (rows - 1) - row;
const gx = anchorX + lx;
const gy = anchorY + ly;
const key = `${gx},${gy}`;
// Collision Check
if (this.occupiedCells.has(key)) {
return false;
}
}
}
return true; // All clear.
return true;
}
/**
* Officially registers a tile onto the board.
* Should only be called AFTER canPlace returns true.
* Registers the tile cells as occupied.
*/
placeTile(tileInstance, tileDef) {
const cells = this.getGlobalCells(tileDef, tileInstance.x, tileInstance.y, tileInstance.rotation);
placeTile(tileInstance, variant) {
const layout = variant.layout;
const rows = layout.length;
const anchorX = tileInstance.x;
const anchorY = tileInstance.y;
// Record every cell in our Map
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
this.occupiedCells.set(key, tileInstance.id);
}
// Store the instance
this.tiles.push(tileInstance);
console.log(`Placed tile ${tileInstance.id} at ${tileInstance.x},${tileInstance.y}`);
}
/**
* THE MAGIC MATH FUNCTION.
* Converts a simplified abstract tile (width/length) into actual grid coordinates.
* Handles the Rotation logic (N, S, E, W).
* NOW SUPPORTS: Matrix Layouts (0 = Empty).
*/
getGlobalCells(tileDef, startX, startY, rotation) {
const cells = [];
const layout = tileDef.layout;
// Safety check: if no layout, fallback to full rectangle (optional, but good for stability)
// usage: const w = tileDef.width; const l = tileDef.length;
if (!layout) {
console.error("Tile definition missing layout. ID:", tileDef?.id);
console.warn("Invalid tileDef object:", tileDef);
return cells;
}
const numberOfRows = layout.length; // usually equals tileDef.length
// Iterate through matrix rows
for (let row = 0; row < numberOfRows; row++) {
for (let row = 0; row < rows; row++) {
const rowData = layout[row];
const numberOfCols = rowData.length; // usually equals tileDef.width
const cols = rowData.length;
for (let col = 0; col < numberOfCols; col++) {
const cellValue = rowData[col];
// CRITICAL: Skip empty cells (0)
if (cellValue === 0) continue;
// Map Matrix (Row, Col) to Local Grid (lx, ly)
// Matrix Row 0 is the "Top" (Max Y).
// Matrix Row (Rows-1) is the "Bottom" (Y=0).
// So: ly = (numberOfRows - 1) - row
// lx = col
for (let col = 0; col < cols; col++) {
if (rowData[col] === 0) continue;
const lx = col;
const ly = (numberOfRows - 1) - row;
const ly = (rows - 1) - row;
let gx, gy;
const gx = anchorX + lx;
const gy = anchorY + ly;
const key = `${gx},${gy}`;
// Apply Rotation to the local (lx, ly) point relative to (0,0) anchor
switch (rotation) {
case DIRECTIONS.NORTH:
// Standard: +X is Right, +Y is Forward
gx = startX + lx;
gy = startY + ly;
break;
case DIRECTIONS.SOUTH:
// 180 degrees: Extension goes "Backwards" and "Leftwards" relative to pivot
gx = startX - lx;
gy = startY - ly;
break;
case DIRECTIONS.EAST:
// 90 degrees Clockwise: Width becomes "Length", Length becomes "Width"
// x' = y, y' = -x
gx = startX + ly;
gy = startY - lx;
break;
case DIRECTIONS.WEST:
// 270 degrees Clockwise (or 90 Counter-Clockwise)
// x' = -y, y' = x
gx = startX - ly;
gy = startY + lx;
break;
default:
gx = startX + lx;
gy = startY + ly;
}
this.occupiedCells.set(key, tileInstance.id);
}
}
this.tiles.push(tileInstance);
console.log(`[Grid] Placed ${tileInstance.id} at ${anchorX},${anchorY} (Rot: ${tileInstance.rotation})`);
}
// We could also store the 'cellValue' (height) if we wanted.
cells.push({ x: gx, y: gy, value: cellValue });
/**
* Helper to return global cells for logic/renderer without modifying state.
*/
calculateCells(variant, anchorX, anchorY) {
const cells = [];
const layout = variant.layout;
const rows = layout.length;
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 lx = col;
const ly = (rows - 1) - row;
const gx = anchorX + lx;
const gy = anchorY + ly;
cells.push({ x: gx, y: gy, value: rowData[col] });
}
}
return cells;
}
/**
* Transforms a local point (like an exit definition) to Global Coordinates.
* Useful for calculating where an exit actually ends up on the board.
* Helper to see if a specific global coordinate is occupied
*/
getGlobalPoint(localX, localY, tileInstance) {
let gx, gy;
const startX = tileInstance.x;
const startY = tileInstance.y;
const rotation = tileInstance.rotation;
switch (rotation) {
case DIRECTIONS.NORTH:
gx = startX + localX;
gy = startY + localY;
break;
case DIRECTIONS.SOUTH:
gx = startX - localX;
gy = startY - localY;
break;
case DIRECTIONS.EAST:
gx = startX + localY;
gy = startY - localX;
break;
case DIRECTIONS.WEST:
gx = startX - localY;
gy = startY + localX;
break;
default:
gx = startX + localX;
gy = startY + localY;
}
return { x: gx, y: gy };
}
/**
* Rotates a direction (N, S, E, W) by a given amount.
* Useful for calculating which way an exit faces after the tile is rotated.
*/
getRotatedDirection(originalDirection, tileRotation) {
// N=0, E=1, S=2, W=3
const dirs = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const idx = dirs.indexOf(originalDirection);
let rotationSteps = 0;
if (tileRotation === DIRECTIONS.EAST) rotationSteps = 1;
if (tileRotation === DIRECTIONS.SOUTH) rotationSteps = 2;
if (tileRotation === DIRECTIONS.WEST) rotationSteps = 3;
const newIdx = (idx + rotationSteps) % 4;
return dirs[newIdx];
isOccupied(x, y) {
return this.occupiedCells.has(`${x},${y}`);
}
}

View File

@@ -1,151 +1,379 @@
import { DIRECTIONS, TILE_TYPES } from './Constants.js';
export const TILES = [
{
export const TILES = {
// -------------------------------------------------------------------------
// CORRIDOR STRAIGHT
// -------------------------------------------------------------------------
'corridor_straight': {
id: 'corridor_straight',
name: 'Corridor',
type: TILE_TYPES.CORRIDOR,
width: 2,
length: 6,
textures: ['/assets/images/dungeon1/tiles/corridor1.png'],
layout: [
[1, 1], // y=5 (North)
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1] // y=0 (South)
],
exits: [
// South
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
// North
{ x: 0, y: 5, direction: DIRECTIONS.NORTH },
{ x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.SOUTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.WEST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
}
}
},
{
// -------------------------------------------------------------------------
// CORRIDOR STEPS
// -------------------------------------------------------------------------
'corridor_steps': {
id: 'corridor_steps',
name: 'Steps',
type: TILE_TYPES.CORRIDOR,
width: 2,
length: 6,
textures: ['/assets/images/dungeon1/tiles/stairs1.png'],
layout: [
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1]
],
exits: [
// South
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
// North
{ x: 0, y: 5, direction: DIRECTIONS.NORTH },
{ x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.SOUTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.WEST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
}
}
},
{
// -------------------------------------------------------------------------
// CORNER (L-Shape)
// -------------------------------------------------------------------------
'corridor_corner': {
id: 'corridor_corner',
name: 'Corner',
type: TILE_TYPES.CORRIDOR,
width: 4,
length: 4,
textures: ['/assets/images/dungeon1/tiles/L.png'],
layout: [
[1, 1, 1, 1], // y=3 (Top)
[1, 1, 1, 1], // y=2 (East Exit here at x=3)
[1, 1, 0, 0], // y=1
[1, 1, 0, 0] // y=0 (South Exit here at x=0,1)
],
exits: [
// South
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
// East
{ x: 3, y: 2, direction: DIRECTIONS.EAST },
{ x: 3, y: 3, direction: DIRECTIONS.EAST }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 4,
layout: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 0, 0],
[1, 1, 0, 0]
],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 2, direction: DIRECTIONS.EAST }, { x: 3, y: 3, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 4,
layout: [
[1, 1, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }, { x: 3, y: 3, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 4,
layout: [
[0, 0, 1, 1],
[0, 0, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: [
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }, { x: 3, y: 3, direction: DIRECTIONS.NORTH },
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 4,
layout: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[0, 0, 1, 1],
[0, 0, 1, 1]
],
exits: [
{ x: 3, y: 2, direction: DIRECTIONS.EAST }, { x: 3, y: 3, direction: DIRECTIONS.EAST },
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 0, direction: DIRECTIONS.WEST }
]
}
}
},
{
// -------------------------------------------------------------------------
// T-JUNCTION
// -------------------------------------------------------------------------
'junction_t': {
id: 'junction_t',
name: 'T-Junction',
type: TILE_TYPES.JUNCTION,
width: 6,
length: 4,
textures: ['/assets/images/dungeon1/tiles/T.png'],
layout: [
[1, 1, 1, 1, 1, 1], // y=3
[1, 1, 1, 1, 1, 1], // y=2 (West at x=0, East at x=5)
[0, 0, 1, 1, 0, 0], // y=1
[0, 0, 1, 1, 0, 0] // y=0 (South at x=2,3)
],
exits: [
// South
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH },
// West
{ x: 0, y: 2, direction: DIRECTIONS.WEST },
{ x: 0, y: 3, direction: DIRECTIONS.WEST },
// East
{ x: 5, y: 2, direction: DIRECTIONS.EAST },
{ x: 5, y: 3, direction: DIRECTIONS.EAST }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 6, height: 4,
layout: [
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1],
[0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 0, 0]
],
exits: [
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH }, { x: 3, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 2, direction: DIRECTIONS.WEST }, { x: 0, y: 3, direction: DIRECTIONS.WEST },
{ x: 5, y: 2, direction: DIRECTIONS.EAST }, { x: 5, y: 3, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 6,
layout: [
[1, 1, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 0, 0],
[1, 1, 0, 0]
],
exits: [
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH }, { x: 3, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 5, direction: DIRECTIONS.NORTH }, { x: 3, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 2, direction: DIRECTIONS.WEST }, { x: 0, y: 3, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.SOUTH]: {
width: 6, height: 4,
layout: [
[0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 0, 0],
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]
],
exits: [
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }, { x: 3, y: 3, direction: DIRECTIONS.NORTH },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 6,
layout: [
[0, 0, 1, 1],
[0, 0, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[0, 0, 1, 1],
[0, 0, 1, 1]
],
exits: [
{ x: 3, y: 2, direction: DIRECTIONS.EAST }, { x: 3, y: 3, direction: DIRECTIONS.EAST },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH }
]
}
}
},
{
// -------------------------------------------------------------------------
// ROOM DUNGEON
// -------------------------------------------------------------------------
'room_dungeon': {
id: 'room_dungeon',
name: 'Dungeon Room',
type: TILE_TYPES.ROOM,
width: 4,
length: 4,
textures: [
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
],
layout: [
[1, 1, 1, 1], // y=3 (North Exit at x=1,2)
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1] // y=0 (South Exit at x=1,2)
],
exits: [
// South
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH },
// North
{ x: 1, y: 3, direction: DIRECTIONS.NORTH },
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST },
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST },
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
}
}
},
{
// -------------------------------------------------------------------------
// ROOM OBJECTIVE
// -------------------------------------------------------------------------
'room_objective': {
id: 'room_objective',
name: 'Dungeon Room',
type: TILE_TYPES.ROOM,
textures: [
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
],
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST },
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST },
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
}
}
},
// -------------------------------------------------------------------------
// ROOM OBJECTIVE
// -------------------------------------------------------------------------
'room_objective': {
id: 'room_objective',
name: 'Objective Room',
type: TILE_TYPES.OBJECTIVE_ROOM,
width: 4,
length: 8,
textures: [
'/assets/images/dungeon1/tiles/room_4x8_altar.png',
'/assets/images/dungeon1/tiles/room_4x8_tomb.png'
],
layout: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1] // South Exit
],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 8,
layout: [
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1],
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]
],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
},
[DIRECTIONS.EAST]: {
width: 8, height: 4,
layout: [
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1]
],
exits: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 8,
layout: [
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1],
[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]
],
exits: [
{ x: 1, y: 7, direction: DIRECTIONS.NORTH }, { x: 2, y: 7, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.WEST]: {
width: 8, height: 4,
layout: [
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1]
],
exits: [
{ x: 7, y: 1, direction: DIRECTIONS.EAST }, { x: 7, y: 2, direction: DIRECTIONS.EAST }
]
}
}
}
];
};

View File

@@ -0,0 +1,181 @@
import { DIRECTIONS } from './Constants.js';
import { GridSystem } from './GridSystem.js';
import { DungeonDeck } from './DungeonDeck.js';
export class DungeonGenerator {
constructor() {
this.grid = new GridSystem();
this.deck = new DungeonDeck();
this.pendingExits = [];
this.placedTiles = [];
this.isComplete = false;
}
startDungeon(missionConfig) {
const objectiveId = missionConfig?.type === 'quest' ? 'room_objective' : 'room_dungeon';
this.deck.generateMissionDeck(objectiveId);
const firstCard = this.deck.draw();
if (!firstCard) {
console.error("❌ Deck empty!");
return;
}
// Place first tile with NORTH variant
this.placeTileAt(firstCard, 0, 0, DIRECTIONS.NORTH);
console.log(`🏰 Dungeon started with ${firstCard.name}`);
}
step() {
if (this.isComplete || this.pendingExits.length === 0) {
this.isComplete = true;
return false;
}
const card = this.deck.draw();
if (!card) {
this.isComplete = true;
return false;
}
const targetExit = this.pendingExits.shift();
const placed = this.tryPlaceCard(card, targetExit);
if (!placed) {
this.pendingExits.push(targetExit);
console.warn(`⚠️ Failed to place ${card.name} - returning exit to pending queue`);
}
return true;
}
tryPlaceCard(card, targetExit) {
console.log(`\n🃏 Trying to place card: ${card.name} (ID: ${card.id})`);
// Use standard opposite logic for Directions (String) since names don't change
const requiredDirection = this.opposite(targetExit.direction);
const targetCell = this.neighbor(targetExit.x, targetExit.y, targetExit.direction);
const rotations = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const shuffled = this.shuffle(rotations);
let bestPlacement = null;
let maxConnections = -1;
// Try ALL rotations
for (const rotation of shuffled) {
// 1. Get the pre-calculated VARIANT for this rotation
const variant = card.variants[rotation];
if (!variant) continue;
const variantExits = variant.exits;
// 2. Find candidate exits on this variant that face the required direction
// Note: variant.exits are ALREADY correctly oriented for this rotation
const candidates = variantExits.filter(e => e.direction === requiredDirection);
for (const candidate of candidates) {
// Determine absolute anchor position
// Since variant coords are all positive relative to (0,0), anchor calculation is simple subtraction
const anchorX = targetCell.x - candidate.x;
const anchorY = targetCell.y - candidate.y;
if (this.grid.canPlace(card, anchorX, anchorY, rotation)) {
// Start Score at 0
let score = 0;
// Calculate score
for (const exit of variantExits) {
const globalX = anchorX + exit.x;
const globalY = anchorY + exit.y;
const neighbor = this.neighbor(globalX, globalY, exit.direction);
const neighborKey = `${neighbor.x},${neighbor.y}`;
if (this.grid.occupiedCells.has(neighborKey)) {
score++;
}
}
// Prefer higher score.
if (score > maxConnections) {
maxConnections = score;
bestPlacement = { card, anchorX, anchorY, rotation };
}
}
}
}
if (bestPlacement) {
this.placeTileAt(bestPlacement.card, bestPlacement.anchorX, bestPlacement.anchorY, bestPlacement.rotation);
console.log(`✅ Placed ${card.name} at (${bestPlacement.anchorX}, ${bestPlacement.anchorY}) Rot:${bestPlacement.rotation} Score:${maxConnections}`);
return true;
}
console.log(`❌ Could not place ${card.name} in any rotation`);
return false;
}
placeTileAt(tileDef, x, y, rotation) {
const instance = {
id: `tile_${this.placedTiles.length}`,
defId: tileDef.id,
x, y, rotation
};
this.grid.placeTile(instance, tileDef); // Grid system now handles looking up the variant
this.placedTiles.push(instance);
// Add exits from the variant
const variant = tileDef.variants[rotation];
for (const exit of variant.exits) {
const globalX = x + exit.x;
const globalY = y + exit.y;
const neighborCell = this.neighbor(globalX, globalY, exit.direction);
const key = `${neighborCell.x},${neighborCell.y}`;
// Only add exit if it leads to empty space
if (!this.grid.occupiedCells.has(key)) {
this.pendingExits.push({
x: globalX,
y: globalY,
direction: exit.direction
});
}
}
// Cleanup pending exits that are now connected
this.pendingExits = this.pendingExits.filter(ex => {
const n = this.neighbor(ex.x, ex.y, ex.direction);
return !this.grid.occupiedCells.has(`${n.x},${n.y}`);
});
}
neighbor(x, y, direction) {
switch (direction) {
case DIRECTIONS.NORTH: return { x, y: y + 1 };
case DIRECTIONS.SOUTH: return { x, y: y - 1 };
case DIRECTIONS.EAST: return { x: x + 1, y };
case DIRECTIONS.WEST: return { x: x - 1, y };
}
}
opposite(direction) {
switch (direction) {
case DIRECTIONS.NORTH: return DIRECTIONS.SOUTH;
case DIRECTIONS.SOUTH: return DIRECTIONS.NORTH;
case DIRECTIONS.EAST: return DIRECTIONS.WEST;
case DIRECTIONS.WEST: return DIRECTIONS.EAST;
}
}
shuffle(arr) {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
}

View File

@@ -0,0 +1,134 @@
import { DIRECTIONS } from './Constants.js';
export class GridSystem {
/**
* The GridSystem maintains the "Source of Truth" for the dungeon layout.
* It knows which cells are occupied and by whom.
* Dependencies: Constants.js (DIRECTIONS)
*/
constructor() {
// We use a Map for O(1) lookups.
// Key: "x,y" (String) -> Value: "tileId" (String)
this.occupiedCells = new Map();
// We also keep a list of placed tile objects for easier iteration if needed later.
this.tiles = [];
}
/**
* Checks if a tile can be placed at the given coordinates with the given rotation.
* Needs: The Tile Definition (to know size), the target X,Y, and desired Rotation.
*/
canPlace(tileDef, startX, startY, rotation) {
// 1. Calculate the real-world coordinates of every single cell this tile would occupy.
const cells = this.getGlobalCells(tileDef, startX, startY, rotation);
// 2. Check each cell against our Map of occupied spots.
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
if (this.occupiedCells.has(key)) {
return false; // COLLISION! Spot already taken.
}
}
return true; // All clear.
}
/**
* Officially registers a tile onto the board.
* Should only be called AFTER canPlace returns true.
*/
placeTile(tileInstance, tileDef) {
const cells = this.getGlobalCells(tileDef, tileInstance.x, tileInstance.y, tileInstance.rotation);
// Record every cell in our Map
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
this.occupiedCells.set(key, tileInstance.id);
}
// Store the instance
this.tiles.push(tileInstance);
console.log(`Placed tile ${tileInstance.id} at ${tileInstance.x},${tileInstance.y}`);
}
/**
* THE CORRECTED MAGIC MATH FUNCTION.
* Now uses Pre-Calculated Variants from TileDefinitions.
* NO DYNAMIC ROTATION MATH performed here.
* All variants are treated as "North" oriented blocks.
*/
getGlobalCells(tileDef, startX, startY, rotation) {
const cells = [];
// 1. Retrieve the specific variant for this rotation
const variant = tileDef.variants[rotation];
if (!variant) {
console.error(`Missing variant for rotation ${rotation} in tile ${tileDef.id}`);
return cells;
}
const layout = variant.layout;
if (!layout) {
console.error("Variant missing layout. ID:", tileDef?.id, rotation);
return cells;
}
const numberOfRows = layout.length;
// Iterate through matrix rows
for (let row = 0; row < numberOfRows; row++) {
const rowData = layout[row];
const numberOfCols = rowData.length;
for (let col = 0; col < numberOfCols; col++) {
const cellValue = rowData[col];
// Skip empty cells (0)
if (cellValue === 0) continue;
// Map Matrix (Row, Col) to Local Grid (lx, ly)
// Matrix Row 0 is the "Top" (Max Y) relative to the tile's origin
// Matrix Row (Rows-1) is the "Bottom" (Y=0)
// lx grows right (col)
// ly grows up (rows reversed)
const lx = col;
const ly = (numberOfRows - 1) - row;
// SIMPLIFIED LOGIC:
// Since the variant layout is ALREADY rotated, we always just ADD the offsets.
// We treat every variant as if it's placed "North-wise" at the anchor point.
const gx = startX + lx;
const gy = startY + ly;
cells.push({ x: gx, y: gy, value: cellValue });
}
}
return cells;
}
/**
* Transforms a local point (like an exit definition) to Global Coordinates.
* Simplified: Just adds offsets because variants carry pre-rotated coords.
*/
getGlobalPoint(localX, localY, tileInstance) {
const startX = tileInstance.x;
const startY = tileInstance.y;
// Simple translation. Rotation is handled by the variant properties upstream.
return {
x: startX + localX,
y: startY + localY
};
}
/**
* DEPRECATED / LEGACY
* Kept just in case, but shouldn't be needed with explicit variants.
*/
getRotatedDirection(originalDirection, tileRotation) {
// With explicit variants, the exit.direction is ALREADY the final global direction.
return originalDirection;
}
}

View File

@@ -1,196 +1,105 @@
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { GAME_PHASES } from './GameConstants.js';
import { Entity } from './Entity.js';
/**
* GameEngine for Manual Dungeon Construction with Player Movement
*/
export class GameEngine {
constructor() {
this.dungeon = new DungeonGenerator();
this.turnManager = new TurnManager();
this.missionConfig = null;
this.player = null;
this.selectedEntity = null;
this.isRunning = false;
this.selectedPath = [];
this.selectedEntityId = null;
// Simple event callbacks (external systems can assign these)
this.onPathChange = null; // (path) => {}
this.onEntityUpdate = null; // (entity) => {}
this.onEntityMove = null; // (entity, path) => {}
this.onEntitySelect = null; // (entityId, isSelected) => {}
this.setupEventHooks();
}
setupEventHooks() {
this.turnManager.on('phase_changed', (phase) => this.handlePhaseChange(phase));
// Callbacks
this.onEntityUpdate = null;
this.onEntityMove = null;
this.onEntitySelect = null;
this.onPathChange = null;
}
startMission(missionConfig) {
this.missionConfig = missionConfig;
console.log(`[GameEngine] Starting mission: ${missionConfig.name}`);
// 1. Initialize Dungeon (Places the first room)
console.log('[GameEngine] Starting mission:', missionConfig.name);
this.dungeon.startDungeon(missionConfig);
// 2. Initialize Player
// Find a valid starting spot (first occupied cell)
const startingSpot = this.dungeon.grid.occupiedCells.keys().next().value;
const [startX, startY] = startingSpot ? startingSpot.split(',').map(Number) : [0, 0];
// Create player at center of first tile
this.createPlayer(1.5, 2.5); // Center of 2x6 corridor
console.log(`[GameEngine] Spawning player at valid spot: ${startX}, ${startY}`);
this.player = new Entity('p1', 'Barbaro', 'hero', startX, startY, '/assets/images/dungeon1/standees/barbaro.png');
if (this.onEntityUpdate) this.onEntityUpdate(this.player);
// 3. Start the Turn Sequence
this.turnManager.startGame();
this.isRunning = true;
}
update(deltaTime) {
// Continuous logic
createPlayer(x, y) {
this.player = {
id: 'p1',
name: 'Barbarian',
x: Math.floor(x),
y: Math.floor(y),
texturePath: '/assets/images/dungeon1/standees/barbaro.png'
};
if (this.onEntityUpdate) {
this.onEntityUpdate(this.player);
}
console.log('[GameEngine] Player created at', this.player.x, this.player.y);
}
handlePhaseChange(phase) {
console.log(`[GameEngine] Phase Changed to: ${phase}`);
}
// --- Interaction ---
onCellClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
console.log(`Cell Click: ${x},${y}`);
// 1. Selector Check (Click on Player?)
if (this.player.x === x && this.player.y === y) {
if (this.selectedEntityId === this.player.id) {
// Deselect
this.selectedEntityId = null;
console.log("Player Deselected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, false);
// Clear path too
this.selectedPath = [];
this._notifyPath();
} else {
// Select
this.selectedEntityId = this.player.id;
console.log("Player Selected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, true);
// 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);
}
console.log('[GameEngine] Player selected');
return;
}
// If nothing selected, ignore floor clicks
if (!this.selectedEntityId) return;
// 2. Check if valid Floor (Occupied Cell)
if (!this.dungeon.grid.occupiedCells.has(`${x},${y}`)) {
console.log("Invalid cell: Void");
return;
}
// 3. Logic: Path Building (Only if Selected)
// A. If clicking on last selected -> Deselect (Remove last step)
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
this.selectedPath.pop();
this._notifyPath();
return;
}
}
// B. Determine Previous Point (Player or Last Path Node)
let prevX = this.player.x;
let prevY = this.player.y;
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
prevX = last.x;
prevY = last.y;
}
// Note: Manhattan distance 1 = Adjacency (No diagonals)
const dist = Math.abs(x - prevX) + Math.abs(y - prevY);
if (dist === 1) {
// Check Path Length Limit (Speed)
if (this.selectedPath.length < this.player.stats.move) {
this.selectedPath.push({ x, y });
this._notifyPath();
// If player selected, move to clicked cell
if (this.selectedEntity === this.player) {
if (this.canMoveTo(x, y)) {
this.movePlayer(x, y);
} else {
console.log("Max movement reached");
}
} else {
// Restart path if clicking adjacent to player
const distFromPlayer = Math.abs(x - this.player.x) + Math.abs(y - this.player.y);
if (distFromPlayer === 1) {
this.selectedPath = [{ x, y }];
this._notifyPath();
console.log('[GameEngine] Cannot move there');
}
}
}
onCellRightClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
if (!this.selectedEntityId) return; // Must satisfy selection rule
canMoveTo(x, y) {
// Check if cell is walkable (occupied by a tile)
return this.dungeon.grid.isOccupied(x, y);
}
// Must be clicking the last tile of the path to confirm
if (this.selectedPath.length === 0) return;
movePlayer(x, y) {
// Simple direct movement (no pathfinding for now)
const path = [{ x, y }];
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
console.log("Confirming Move...");
this.confirmMove();
this.player.x = x;
this.player.y = y;
if (this.onEntityMove) {
this.onEntityMove(this.player, path);
}
}
confirmMove() {
if (this.selectedPath.length === 0) return;
const target = this.selectedPath[this.selectedPath.length - 1];
const pathCopy = [...this.selectedPath];
// 1. Trigger Animation Sequence
if (this.onEntityMove) this.onEntityMove(this.player, pathCopy);
// 2. Update Logical Position
this.player.setPosition(target.x, target.y);
// 3. Cleanup
this.selectedPath = [];
this._notifyPath();
// Note: Exploration is now manual via door interaction
}
_notifyPath() {
if (this.onPathChange) this.onPathChange(this.selectedPath);
}
exploreExit(exitCell) {
console.log('[GameEngine] Exploring exit:', exitCell);
// Find this exit in pendingExits
const exit = this.dungeon.pendingExits.find(ex => ex.x === exitCell.x && ex.y === exitCell.y);
if (exit) {
// Prioritize this exit
const idx = this.dungeon.pendingExits.indexOf(exit);
if (idx > -1) {
this.dungeon.pendingExits.splice(idx, 1);
this.dungeon.pendingExits.unshift(exit);
}
// Trigger exploration
this.turnManager.triggerExploration();
this.dungeon.step();
this.turnManager.setPhase(GAME_PHASES.HERO);
} else {
console.warn('[GameEngine] Exit not found in pendingExits');
// Deselect after move
this.selectedEntity = null;
if (this.onEntitySelect) {
this.onEntitySelect(this.player.id, false);
}
console.log('[GameEngine] Player moved to', x, y);
}
isPlayerAdjacentToDoor(doorExit) {
if (!this.player) return false;
const dx = Math.abs(this.player.x - doorExit.x);
const dy = Math.abs(this.player.y - doorExit.y);
// Adjacent means distance of 1 in one direction and 0 in the other
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
}
update(time) {
// Minimal update loop
}
}

View File

@@ -0,0 +1,196 @@
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { GAME_PHASES } from './GameConstants.js';
import { Entity } from './Entity.js';
export class GameEngine {
constructor() {
this.dungeon = new DungeonGenerator();
this.turnManager = new TurnManager();
this.missionConfig = null;
this.player = null;
this.selectedPath = [];
this.selectedEntityId = null;
// Simple event callbacks (external systems can assign these)
this.onPathChange = null; // (path) => {}
this.onEntityUpdate = null; // (entity) => {}
this.onEntityMove = null; // (entity, path) => {}
this.onEntitySelect = null; // (entityId, isSelected) => {}
this.setupEventHooks();
}
setupEventHooks() {
this.turnManager.on('phase_changed', (phase) => this.handlePhaseChange(phase));
}
startMission(missionConfig) {
this.missionConfig = missionConfig;
console.log(`[GameEngine] Starting mission: ${missionConfig.name}`);
// 1. Initialize Dungeon (Places the first room)
this.dungeon.startDungeon(missionConfig);
// 2. Initialize Player
// Find a valid starting spot (first occupied cell)
const startingSpot = this.dungeon.grid.occupiedCells.keys().next().value;
const [startX, startY] = startingSpot ? startingSpot.split(',').map(Number) : [0, 0];
console.log(`[GameEngine] Spawning player at valid spot: ${startX}, ${startY}`);
this.player = new Entity('p1', 'Barbaro', 'hero', startX, startY, '/assets/images/dungeon1/standees/barbaro.png');
if (this.onEntityUpdate) this.onEntityUpdate(this.player);
// 3. Start the Turn Sequence
this.turnManager.startGame();
}
update(deltaTime) {
// Continuous logic
}
handlePhaseChange(phase) {
console.log(`[GameEngine] Phase Changed to: ${phase}`);
}
// --- Interaction ---
onCellClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
console.log(`Cell Click: ${x},${y}`);
// 1. Selector Check (Click on Player?)
if (this.player.x === x && this.player.y === y) {
if (this.selectedEntityId === this.player.id) {
// Deselect
this.selectedEntityId = null;
console.log("Player Deselected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, false);
// Clear path too
this.selectedPath = [];
this._notifyPath();
} else {
// Select
this.selectedEntityId = this.player.id;
console.log("Player Selected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, true);
}
return;
}
// If nothing selected, ignore floor clicks
if (!this.selectedEntityId) return;
// 2. Check if valid Floor (Occupied Cell)
if (!this.dungeon.grid.occupiedCells.has(`${x},${y}`)) {
console.log("Invalid cell: Void");
return;
}
// 3. Logic: Path Building (Only if Selected)
// A. If clicking on last selected -> Deselect (Remove last step)
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
this.selectedPath.pop();
this._notifyPath();
return;
}
}
// B. Determine Previous Point (Player or Last Path Node)
let prevX = this.player.x;
let prevY = this.player.y;
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
prevX = last.x;
prevY = last.y;
}
// Note: Manhattan distance 1 = Adjacency (No diagonals)
const dist = Math.abs(x - prevX) + Math.abs(y - prevY);
if (dist === 1) {
// Check Path Length Limit (Speed)
if (this.selectedPath.length < this.player.stats.move) {
this.selectedPath.push({ x, y });
this._notifyPath();
} else {
console.log("Max movement reached");
}
} else {
// Restart path if clicking adjacent to player
const distFromPlayer = Math.abs(x - this.player.x) + Math.abs(y - this.player.y);
if (distFromPlayer === 1) {
this.selectedPath = [{ x, y }];
this._notifyPath();
}
}
}
onCellRightClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
if (!this.selectedEntityId) return; // Must satisfy selection rule
// Must be clicking the last tile of the path to confirm
if (this.selectedPath.length === 0) return;
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
console.log("Confirming Move...");
this.confirmMove();
}
}
confirmMove() {
if (this.selectedPath.length === 0) return;
const target = this.selectedPath[this.selectedPath.length - 1];
const pathCopy = [...this.selectedPath];
// 1. Trigger Animation Sequence
if (this.onEntityMove) this.onEntityMove(this.player, pathCopy);
// 2. Update Logical Position
this.player.setPosition(target.x, target.y);
// 3. Cleanup
this.selectedPath = [];
this._notifyPath();
// Note: Exploration is now manual via door interaction
}
_notifyPath() {
if (this.onPathChange) this.onPathChange(this.selectedPath);
}
exploreExit(exitCell) {
console.log('[GameEngine] Exploring exit:', exitCell);
// Find this exit in pendingExits
const exit = this.dungeon.pendingExits.find(ex => ex.x === exitCell.x && ex.y === exitCell.y);
if (exit) {
// Prioritize this exit
const idx = this.dungeon.pendingExits.indexOf(exit);
if (idx > -1) {
this.dungeon.pendingExits.splice(idx, 1);
this.dungeon.pendingExits.unshift(exit);
}
// Trigger exploration
this.turnManager.triggerExploration();
this.dungeon.step();
this.turnManager.setPhase(GAME_PHASES.HERO);
} else {
console.warn('[GameEngine] Exit not found in pendingExits');
}
}
}

View File

@@ -1,69 +1,50 @@
import { GameEngine } from './engine/game/GameEngine.js';
import { GameRenderer } from './view/GameRenderer.js';
import { CameraManager } from './view/CameraManager.js';
import { UIManager } from './view/UIManager.js';
import { DoorModal } from './view/DoorModal.js';
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
console.log("Initializing Warhammer Quest Engine... SYSTEM: GAME_LOOP_ARC_V1");
window.TEXTURE_DEBUG = true;
console.log("🏗️ Warhammer Quest - Manual Dungeon Construction");
// 1. Setup Mission
const mission = new MissionConfig({
id: 'mission_1',
name: 'The First Dive',
name: 'Manual Construction',
type: MISSION_TYPES.ESCAPE,
minTiles: 6
minTiles: 13
});
// 2. Initialize Core Systems
const renderer = new GameRenderer('app'); // Visuals
const cameraManager = new CameraManager(renderer); // Camera
const game = new GameEngine(); // Logic Brain
// 3. Initialize UI
// UIManager currently reads directly from DungeonGenerator for minimap
const renderer = new GameRenderer('app');
const cameraManager = new CameraManager(renderer);
const game = new GameEngine();
const ui = new UIManager(cameraManager, game);
const doorModal = new DoorModal();
// Global Access for Debugging in Browser Console
// Global Access
window.GAME = game;
window.RENDERER = renderer;
// 4. Bridge Logic & View (Event Hook)
// When logic places a tile, we tell the renderer to spawn 3D meshes.
// Ideally, this should be an Event in GameEngine, but we keep this patch for now to verify.
// 3. Connect Dungeon Generator to Renderer
const generator = game.dungeon;
const originalPlaceTile = generator.grid.placeTile.bind(generator.grid);
generator.grid.placeTile = (instance, def) => {
// 1. Execute Logic
originalPlaceTile(instance, def);
generator.grid.placeTile = (instance, variant, card) => {
originalPlaceTile(instance, variant);
// 2. Execute Visuals
const cells = generator.grid.getGlobalCells(def, instance.x, instance.y, instance.rotation);
renderer.addTile(cells, def.type, def, instance);
const cells = generator.grid.calculateCells(variant, instance.x, instance.y);
renderer.addTile(cells, card.type, card, instance);
// 3. Update Exits Visuals
setTimeout(() => {
renderer.renderExits(generator.pendingExits);
}, 50); // Small delay to ensure logic updated pendingExits
renderer.renderExits(generator.availableExits);
}, 50);
};
// 5. Connect UI Buttons to Game Actions (Temporary)
// We will add a temporary button in pure JS here or modify UIManager later.
// For now, let's expose a global function for the UI to call if needed,
// or simply rely on UIManager updates.
// 6. Start the Game
// 5a. Bridge Game Interactions
// 5a. Bridge Game Interactions
// 4. Connect Player to Renderer
game.onEntityUpdate = (entity) => {
renderer.addEntity(entity);
renderer.updateEntityPosition(entity);
// Initial Center on Player Spawn
// Center camera on player spawn
if (entity.id === 'p1' && !entity._centered) {
cameraManager.centerOn(entity.x, entity.y);
entity._centered = true;
@@ -79,85 +60,84 @@ game.onEntitySelect = (entityId, isSelected) => {
};
renderer.onHeroFinishedMove = (x, y) => {
// x, y are World Coordinates (x, -z grid)
// Actually, renderer returns Mesh Position.
// Mesh X = Grid X. Mesh Z = -Grid Y.
// Camera centerOn takes (Grid X, Grid Y).
// So we need to convert back?
// centerOn implementation: this.target.set(x, 0, -y);
// If onHeroFinishedMove passes (mesh.x, -mesh.z), that is (Grid X, Grid Y).
// Let's verify what we passed in renderer:
// this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
// So if mesh is at (5, 1.5, -5), we pass (5, 5).
// centerOn(5, 5) -> target(5, 0, -5). Correct.
cameraManager.centerOn(x, y);
};
game.onPathChange = (path) => {
renderer.highlightCells(path);
// 5. Connect Generator State to UI
generator.onStateChange = (state) => {
console.log(`[Main] State: ${state}`);
if (state === 'PLACING_TILE') {
ui.showPlacementControls(true);
} else {
ui.showPlacementControls(false);
}
};
// Custom click handler that checks for doors first
const handleCellClick = async (x, y, doorMesh) => {
// If doorMesh is provided, user clicked directly on a door texture
generator.onPlacementUpdate = (preview) => {
if (preview) {
renderer.showPlacementPreview(preview);
ui.updatePlacementStatus(preview.isValid);
} else {
renderer.hidePlacementPreview();
}
};
// 6. Handle Clicks
const handleClick = (x, y, doorMesh) => {
// PRIORITY 1: Tile Placement Mode - ignore all clicks
if (generator.state === 'PLACING_TILE') {
console.log('[Main] Use placement controls to place tile');
return;
}
// PRIORITY 2: Door Click (must be adjacent to player)
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
// Get player position
const player = game.player;
if (!player) {
console.log('[Main] Player not found');
return;
}
const doorExit = doorMesh.userData.cells[0];
// Check if player is adjacent to the door
if (renderer.isPlayerAdjacentToDoor(player.x, player.y, doorMesh)) {
// Show modal
const confirmed = await doorModal.show('¿Quieres abrir la puerta?');
if (game.isPlayerAdjacentToDoor(doorExit)) {
console.log('[Main] 🚪 Opening door and drawing tile...');
if (confirmed) {
// Open the door
renderer.openDoor(doorMesh);
// Open door visually
renderer.openDoor(doorMesh);
// Trigger exploration of the next tile
const exitCell = doorMesh.userData.cells[0];
console.log('[Main] Opening door at exit:', exitCell);
// Call game logic to explore through this exit
game.exploreExit(exitCell);
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
} else {
console.error('[Main] Door missing exitData');
}
} else {
console.log('[Main] Player is not adjacent to the door. Move closer first.');
console.log('[Main] ⚠️ Move adjacent to the door first');
}
} else if (x !== null && y !== null) {
// Normal cell click (no door involved)
return;
}
// PRIORITY 3: Normal cell click (player selection/movement)
if (x !== null && y !== null) {
game.onCellClick(x, y);
}
};
renderer.setupInteraction(
() => cameraManager.getCamera(),
handleCellClick,
(x, y) => game.onCellRightClick(x, y)
handleClick,
() => { } // No right-click
);
console.log("--- Starting Game Session ---");
// 7. Start
console.log("--- Starting Game ---");
game.startMission(mission);
// 7. Render Loop
// 8. Render Loop
const animate = (time) => {
requestAnimationFrame(animate);
// Update Game Logic (State Machine, Timers, etc)
game.update(time);
// Update Camera Animations
cameraManager.update(time);
// Update Visual Animations
renderer.updateAnimations(time);
// Render Frame
renderer.render(cameraManager.getCamera());
};
animate(0);
console.log("✅ Ready - Move barbarian next to a door and click it");

172
src/old/main_20260102.js Normal file
View File

@@ -0,0 +1,172 @@
import { GameEngine } from './engine/game/GameEngine.js';
import { GameRenderer } from './view/GameRenderer.js';
import { CameraManager } from './view/CameraManager.js';
import { UIManager } from './view/UIManager.js';
import { DoorModal } from './view/DoorModal.js';
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
console.log("Initializing Warhammer Quest Engine... SYSTEM: MANUAL_PLACEMENT_V2");
window.TEXTURE_DEBUG = true;
// 1. Setup Mission
const mission = new MissionConfig({
id: 'mission_1',
name: 'The First Dive',
type: MISSION_TYPES.ESCAPE,
minTiles: 6
});
// 2. Initialize Core Systems
const renderer = new GameRenderer('app');
const cameraManager = new CameraManager(renderer);
const game = new GameEngine();
// 3. Initialize UI
const ui = new UIManager(cameraManager, game);
const doorModal = new DoorModal();
// Global Access for Debugging
window.GAME = game;
window.RENDERER = renderer;
// 4. Bridge Logic & View
const generator = game.dungeon;
const originalPlaceTile = generator.grid.placeTile.bind(generator.grid);
// Override placeTile to trigger rendering
generator.grid.placeTile = (instance, variant, def) => {
originalPlaceTile(instance, variant);
const cells = generator.grid.calculateCells(variant, instance.x, instance.y);
renderer.addTile(cells, def.type, def, instance);
// Update exits visualization
setTimeout(() => {
renderer.renderExits(generator.availableExits);
}, 50);
};
// 5. Connect Generator State Changes to UI
generator.onStateChange = (state) => {
console.log(`[Main] Generator state: ${state}`);
if (state === 'PLACING_TILE') {
ui.showPlacementControls(true);
} else {
ui.showPlacementControls(false);
}
if (state === 'WAITING_DOOR') {
// Enable door selection mode
renderer.enableDoorSelection(true);
} else {
renderer.enableDoorSelection(false);
}
};
// 6. Connect Placement Updates to Renderer
generator.onPlacementUpdate = (preview) => {
if (preview) {
renderer.showPlacementPreview(preview);
ui.updatePlacementStatus(preview.isValid);
} else {
renderer.hidePlacementPreview();
}
};
// 7. Handle Door Selection
renderer.onDoorSelected = (exitPoint) => {
console.log('[Main] Door selected:', exitPoint);
generator.selectDoor(exitPoint);
};
// 8. Bridge Game Interactions (Player movement, etc.)
game.onEntityUpdate = (entity) => {
renderer.addEntity(entity);
renderer.updateEntityPosition(entity);
if (entity.id === 'p1' && !entity._centered) {
cameraManager.centerOn(entity.x, entity.y);
entity._centered = true;
}
};
game.onEntityMove = (entity, path) => {
renderer.moveEntityAlongPath(entity, path);
};
game.onEntitySelect = (entityId, isSelected) => {
renderer.toggleEntitySelection(entityId, isSelected);
};
renderer.onHeroFinishedMove = (x, y) => {
cameraManager.centerOn(x, y);
};
game.onPathChange = (path) => {
renderer.highlightCells(path);
};
// 9. Custom click handler
const handleCellClick = async (x, y, doorMesh) => {
// PRIORITY 1: Check if we're in door selection mode (dungeon building)
if (generator.state === 'WAITING_DOOR') {
if (doorMesh && doorMesh.userData.isExit) {
// This is an exit door that can be selected for expansion
const exitData = doorMesh.userData.exitData;
if (exitData) {
console.log('[Main] Door selected for expansion:', exitData);
generator.selectDoor(exitData);
}
return; // Don't process any other click logic
}
// If not clicking on a door in WAITING_DOOR mode, ignore the click
console.log('[Main] Click ignored - waiting for door selection');
return;
}
// PRIORITY 2: Normal door interaction (during gameplay with player)
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
const player = game.player;
if (!player) {
console.log('[Main] Player not found');
return;
}
if (renderer.isPlayerAdjacentToDoor(player.x, player.y, doorMesh)) {
const confirmed = await doorModal.show('¿Quieres abrir la puerta?');
if (confirmed) {
renderer.openDoor(doorMesh);
const exitCell = doorMesh.userData.cells[0];
console.log('[Main] Opening door at exit:', exitCell);
game.exploreExit(exitCell);
}
} else {
console.log('[Main] Player is not adjacent to the door. Move closer first.');
}
} else if (x !== null && y !== null) {
game.onCellClick(x, y);
}
};
renderer.setupInteraction(
() => cameraManager.getCamera(),
handleCellClick,
(x, y) => game.onCellRightClick(x, y)
);
console.log("--- Starting Game Session ---");
game.startMission(mission);
// 10. Render Loop
const animate = (time) => {
requestAnimationFrame(animate);
game.update(time);
cameraManager.update(time);
renderer.updateAnimations(time);
renderer.render(cameraManager.getCamera());
};
animate(0);

View File

@@ -412,11 +412,18 @@ export class GameRenderer {
mesh.rotation.y = angle;
// Store door data for interaction (new doors always start closed)
// Convert numeric direction to string for generator compatibility
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
mesh.userData = {
isDoor: true,
isOpen: false,
cells: [d1, d2],
direction: dir
direction: dir,
exitData: {
x: d1.x,
y: d1.y,
direction: dirMap[dir] || 'N'
}
};
mesh.name = `door_${idx}`;
@@ -480,14 +487,38 @@ export class GameRenderer {
// Load texture with callback
this.getTexture(texturePath, (texture) => {
const w = tileDef.width;
const l = tileDef.length;
// Create Plane
const geometry = new THREE.PlaneGeometry(w, l);
// --- NEW LOGIC: Calculate center based on DIMENSIONS, not CELLS ---
// 1. Get the specific variant for this rotation to know the VISUAL bounds
// (The shape the grid sees: e.g. 4x2 for East)
const currentVariant = tileDef.variants[tileInstance.rotation];
if (!currentVariant) {
console.error(`[GameRenderer] Missing variant for rotation ${tileInstance.rotation}`);
return;
}
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
// 2. Calculate the Geometric Center of the tile relative to the anchor
// Formula: anchor + (dimension - 1) / 2
// (Subtract 1 because width 1 is just offset 0)
const cx = tileInstance.x + (rotWidth - 1) / 2;
const cy = tileInstance.y + (rotHeight - 1) / 2;
console.log(`[GameRenderer] Dimensions (Rotated): ${rotWidth}x${rotHeight}`);
console.log(`[GameRenderer] Calculated Center: (${cx}, ${cy})`);
// 3. Use BASE dimensions from NORTH variant for the Plane
// (Since we are rotating the plane itself, we start with the un-rotated image size)
const baseWidth = tileDef.variants.N.width;
const baseHeight = tileDef.variants.N.height;
// Create Plane with BASE dimensions
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
// SWITCH TO BASIC MATERIAL FOR DEBUGGING TEXTURE VISIBILITY
// Standard material heavily depends on lights. If light is not hitting correctly, it looks black.
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
@@ -496,42 +527,32 @@ export class GameRenderer {
});
const plane = new THREE.Mesh(geometry, material);
// Initial Rotation: Plane X-Y to X-Z
// Initial Rotation: Plane X-Y to X-Z (Flat on ground)
plane.rotation.x = -Math.PI / 2;
// Handle Rotation safely (Support both 0-3 and N-W)
const rotMap = { 'N': 0, '0': 0, 0: 0, 'E': 1, '1': 1, 1: 1, 'S': 2, '2': 2, 2: 2, 'W': 3, '3': 3, 3: 3 };
// Handle Rotation
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
const r = rotMap[tileInstance.rotation] !== undefined ? rotMap[tileInstance.rotation] : 0;
// Apply Tile Rotation
// Apply Tile Rotation (Z-axis is Up in this local frame before X-rotation? No, after X-rot)
// Actually, standard hierarchy: Rotate Z first?
// ThreeJS rotation order XYZ.
// We want to rotate around the Y axis of the world (which is Z of the plane before x-rotation?)
// Simplest: Rotate Z of the plane, which corresponds to world Y.
// Note: We use negative rotation because ThreeJS is CCW, but our grid might be different,
// but usually -r * PI/2 works for this setup.
plane.rotation.z = -r * (Math.PI / 2);
// Calculate Center Offset for Positioning
const midX = (tileDef.width - 1) / 2;
const midY = (tileDef.length - 1) / 2;
// Rotate the offset vector based on tile rotation
let dx, dy;
if (r === 0) { dx = midX; dy = midY; }
else if (r === 1) { dx = midY; dy = -midX; }
else if (r === 2) { dx = -midX; dy = -midY; }
else if (r === 3) { dx = -midY; dy = midX; }
const centerX = tileInstance.x + dx;
const centerY = tileInstance.y + dy;
// Set at almost 0 height to avoid z-fighting with grid helper, but effectively on floor
plane.position.set(centerX, 0.01, -centerY);
// Position at the calculated center
// Notice: World Z is -Grid Y
plane.position.set(cx, 0.01, -cy);
plane.receiveShadow = true;
this.scene.add(plane);
console.log(`[GameRenderer] ✓ Tile plane added at (${centerX}, 0.01, ${-centerY}) for ${tileDef.id}`);
console.log(`[GameRenderer] ✓ Tile plane added at (${cx}, 0.01, ${-cy})`);
});
} else {
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}, tex: ${tileDef?.textures?.length}`);
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`);
}
}
@@ -581,4 +602,156 @@ export class GameRenderer {
return false;
}
// ========== MANUAL PLACEMENT SYSTEM ==========
enableDoorSelection(enabled) {
this.doorSelectionEnabled = enabled;
if (enabled) {
// Highlight available exits
this.highlightAvailableExits();
} else {
// Remove highlights
if (this.exitHighlightGroup) {
this.exitHighlightGroup.clear();
}
}
}
highlightAvailableExits() {
if (!this.exitHighlightGroup) {
this.exitHighlightGroup = new THREE.Group();
this.scene.add(this.exitHighlightGroup);
}
this.exitHighlightGroup.clear();
// Highlight each exit door with a pulsing glow
if (this.exitGroup) {
this.exitGroup.children.forEach(doorMesh => {
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
// Create highlight ring
const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00ff00,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.6
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.copy(doorMesh.position);
ring.position.y = 0.05;
// Store reference to door for click handling
doorMesh.userData.isExit = true;
// Create proper exit data with all required fields
const firstCell = doorMesh.userData.cells[0];
// Convert numeric direction (0,1,2,3) to string ('N','E','S','W')
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
doorMesh.userData.exitData = {
x: firstCell.x,
y: firstCell.y,
direction: dirMap[doorMesh.userData.direction] || 'N'
};
this.exitHighlightGroup.add(ring);
}
});
}
}
showPlacementPreview(preview) {
if (!preview) {
this.hidePlacementPreview();
return;
}
// Create preview groups if they don't exist
if (!this.previewGroup) {
this.previewGroup = new THREE.Group();
this.scene.add(this.previewGroup);
}
if (!this.projectionGroup) {
this.projectionGroup = new THREE.Group();
this.scene.add(this.projectionGroup);
}
// Clear previous preview
this.previewGroup.clear();
this.projectionGroup.clear();
const { card, cells, isValid, x, y, rotation } = preview;
// Calculate bounds for tile - OLD LOGIC (Removed)
// Note: We ignore 'cells' for positioning the texture, but keep them for the Ground Projection (Green/Red squares)
// 1. FLOATING TILE (Y = 3)
if (card.textures && card.textures.length > 0) {
this.getTexture(card.textures[0], (texture) => {
// Get Current Rotation Variant for Dimensions
const currentVariant = card.variants[rotation];
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
// Calculate Center based on Anchor (x, y) and Dimensions
const cx = x + (rotWidth - 1) / 2;
const cy = y + (rotHeight - 1) / 2;
// Use BASE dimensions from NORTH variant
const baseWidth = card.variants.N.width;
const baseHeight = card.variants.N.height;
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
});
const floatingTile = new THREE.Mesh(geometry, material);
floatingTile.rotation.x = -Math.PI / 2;
// Apply Z rotation
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0;
floatingTile.rotation.z = -r * (Math.PI / 2);
console.log(`[Preview] Rotation: ${rotation}, Center: (${cx}, ${cy})`);
floatingTile.position.set(cx, 3, -cy);
this.previewGroup.add(floatingTile);
});
}
// 2. GROUND PROJECTION (Green/Red)
const projectionColor = isValid ? 0x00ff00 : 0xff0000;
cells.forEach(cell => {
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
const material = new THREE.MeshBasicMaterial({
color: projectionColor,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide
});
const projection = new THREE.Mesh(geometry, material);
projection.rotation.x = -Math.PI / 2;
projection.position.set(cell.x, 0.02, -cell.y);
this.projectionGroup.add(projection);
});
}
hidePlacementPreview() {
if (this.previewGroup) {
this.previewGroup.clear();
}
if (this.projectionGroup) {
this.projectionGroup.clear();
}
}
}

View File

@@ -134,6 +134,137 @@ export class UIManager {
// Set initial active button (North)
this.updateActiveViewButton(DIRECTIONS.NORTH);
// --- Tile Placement Controls (Bottom Center) ---
this.placementPanel = document.createElement('div');
this.placementPanel.style.position = 'absolute';
this.placementPanel.style.bottom = '20px';
this.placementPanel.style.left = '50%';
this.placementPanel.style.transform = 'translateX(-50%)';
this.placementPanel.style.display = 'none'; // Hidden by default
this.placementPanel.style.pointerEvents = 'auto';
this.placementPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
this.placementPanel.style.padding = '15px';
this.placementPanel.style.borderRadius = '8px';
this.placementPanel.style.border = '2px solid #666';
this.container.appendChild(this.placementPanel);
// Status text
this.placementStatus = document.createElement('div');
this.placementStatus.style.color = '#fff';
this.placementStatus.style.fontSize = '16px';
this.placementStatus.style.fontFamily = 'sans-serif';
this.placementStatus.style.marginBottom = '10px';
this.placementStatus.style.textAlign = 'center';
this.placementStatus.textContent = 'Coloca la loseta';
this.placementPanel.appendChild(this.placementStatus);
// Controls container
const placementControls = document.createElement('div');
placementControls.style.display = 'flex';
placementControls.style.gap = '15px';
placementControls.style.alignItems = 'center';
this.placementPanel.appendChild(placementControls);
// Movement arrows (4-way grid)
const arrowGrid = document.createElement('div');
arrowGrid.style.display = 'grid';
arrowGrid.style.gridTemplateColumns = '40px 40px 40px';
arrowGrid.style.gap = '3px';
const createArrow = (label, dx, dy) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.width = '40px';
btn.style.height = '40px';
btn.style.backgroundColor = '#444';
btn.style.color = '#fff';
btn.style.border = '1px solid #888';
btn.style.cursor = 'pointer';
btn.style.fontSize = '18px';
btn.onclick = () => {
if (this.dungeon) {
this.dungeon.movePlacement(dx, dy);
}
};
return btn;
};
const arrowUp = createArrow('↑', 0, 1);
const arrowLeft = createArrow('←', -1, 0);
const arrowRight = createArrow('→', 1, 0);
const arrowDown = createArrow('↓', 0, -1);
arrowUp.style.gridColumn = '2';
arrowLeft.style.gridColumn = '1';
arrowRight.style.gridColumn = '3';
arrowDown.style.gridColumn = '2';
arrowGrid.appendChild(arrowUp);
arrowGrid.appendChild(arrowLeft);
arrowGrid.appendChild(arrowRight);
arrowGrid.appendChild(arrowDown);
placementControls.appendChild(arrowGrid);
// Rotate button
this.rotateBtn = document.createElement('button');
this.rotateBtn.textContent = '🔄 Rotar';
this.rotateBtn.style.padding = '10px 20px';
this.rotateBtn.style.backgroundColor = '#555';
this.rotateBtn.style.color = '#fff';
this.rotateBtn.style.border = '1px solid #888';
this.rotateBtn.style.cursor = 'pointer';
this.rotateBtn.style.fontSize = '16px';
this.rotateBtn.style.borderRadius = '4px';
this.rotateBtn.onclick = () => {
if (this.dungeon) {
this.dungeon.rotatePlacement();
}
};
placementControls.appendChild(this.rotateBtn);
// Place button
this.placeBtn = document.createElement('button');
this.placeBtn.textContent = '⬇ Bajar';
this.placeBtn.style.padding = '10px 20px';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.color = '#fff';
this.placeBtn.style.border = '1px solid #888';
this.placeBtn.style.cursor = 'pointer';
this.placeBtn.style.fontSize = '16px';
this.placeBtn.style.borderRadius = '4px';
this.placeBtn.onclick = () => {
if (this.dungeon) {
const success = this.dungeon.confirmPlacement();
if (!success) {
alert('❌ No se puede colocar la loseta en esta posición');
}
}
};
placementControls.appendChild(this.placeBtn);
}
showPlacementControls(show) {
if (this.placementPanel) {
this.placementPanel.style.display = show ? 'block' : 'none';
}
}
updatePlacementStatus(isValid) {
if (this.placementStatus) {
if (isValid) {
this.placementStatus.textContent = '✅ Posición válida';
this.placementStatus.style.color = '#0f0';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.cursor = 'pointer';
} else {
this.placementStatus.textContent = '❌ Posición inválida';
this.placementStatus.style.color = '#f44';
this.placeBtn.style.backgroundColor = '#555';
this.placeBtn.style.cursor = 'not-allowed';
}
}
}
updateActiveViewButton(activeDirection) {
@@ -201,13 +332,15 @@ export class UIManager {
ctx.fillRect(cx, cy, cellSize, cellSize);
}
// Draw Exits (Pending)
// Draw Exits (Available)
ctx.fillStyle = '#0f0'; // Green dots for open exits
this.dungeon.pendingExits.forEach(exit => {
const ex = centerX + (exit.x * cellSize);
const ey = centerY - (exit.y * cellSize);
ctx.fillRect(ex, ey, cellSize, cellSize);
});
if (this.dungeon.availableExits) {
this.dungeon.availableExits.forEach(exit => {
const ex = centerX + (exit.x * cellSize);
const ey = centerY - (exit.y * cellSize);
ctx.fillRect(ex, ey, cellSize, cellSize);
});
}
// Draw Entry (0,0) cross
ctx.strokeStyle = '#f00';