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,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];
}
}