Generador procedural de mazmorras con fog of war y sistema de vistas isométricas
- Generador procedural que crea hasta 10 salas aleatorias conectadas por puertas - Sistema de fog of war: solo se muestran salas visitadas - Puertas automáticas entre salas con detección de transición - 0-2 esqueletos aleatorios por sala - Sistema de vistas NSEW con UI de compás - 4 vistas isométricas fijas (Norte, Sur, Este, Oeste) - Zoom y paneo habilitados, rotación deshabilitada - Paredes con opacidad diferenciada (N/W opacas, S/E semi-transparentes) - Validación de movimiento: solo celdas transitables - Centrado automático de cámara al mover personaje
This commit is contained in:
BIN
assets/images/tiles/pared1.png
Normal file
BIN
assets/images/tiles/pared1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/tiles/puerta1.png
Normal file
BIN
assets/images/tiles/puerta1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 920 KiB |
BIN
assets/images/tiles/tile4x4_green.jpg
Normal file
BIN
assets/images/tiles/tile4x4_green.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
assets/images/tiles/tile8x4.jpg
Normal file
BIN
assets/images/tiles/tile8x4.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
assets/images/tiles/tile8x4.png
Normal file
BIN
assets/images/tiles/tile8x4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
@@ -8,6 +8,12 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="compass">
|
||||
<div id="compass-n" class="compass-btn active" data-direction="N">N</div>
|
||||
<div id="compass-s" class="compass-btn" data-direction="S">S</div>
|
||||
<div id="compass-e" class="compass-btn" data-direction="E">E</div>
|
||||
<div id="compass-w" class="compass-btn" data-direction="W">W</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
723
src/main.js
723
src/main.js
@@ -14,6 +14,8 @@ const ASSETS = {
|
||||
'tile_base': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 4 },
|
||||
'tile_cyan': { src: '/assets/images/tiles/tile4x4_blue.png', width: 4, height: 4 },
|
||||
'tile_orange': { src: '/assets/images/tiles/tile4x4_orange.png', width: 4, height: 4 },
|
||||
'wall_1': { src: '/assets/images/tiles/pared1.png' },
|
||||
'door_1': { src: '/assets/images/tiles/puerta1.png' },
|
||||
},
|
||||
standees: {
|
||||
'hero_1': { src: '/assets/images/standees/barbaro.png', height: 3 },
|
||||
@@ -21,24 +23,126 @@ const ASSETS = {
|
||||
}
|
||||
};
|
||||
|
||||
const GAME_STATE = {
|
||||
placedTiles: [
|
||||
{ id: 1, type: 'tile_base', x: 0, y: 0 },
|
||||
{ id: 2, type: 'tile_cyan', x: 0, y: -4 },
|
||||
{ id: 3, type: 'tile_orange', x: -4, y: 0 } // Oeste de la primera
|
||||
],
|
||||
entities: [
|
||||
{ id: 101, type: 'hero_1', x: 1, y: 1 },
|
||||
{ id: 102, type: 'hero_2', x: 2, y: -2 }
|
||||
]
|
||||
};
|
||||
// Sistema de salas
|
||||
// --- GENERADOR PROCEDURAL DE MAZMORRAS ---
|
||||
function generateDungeon() {
|
||||
const rooms = [];
|
||||
const maxRooms = 10;
|
||||
const tileTypes = ['tile_base', 'tile_base', 'tile_base']; // Solo tile_base (4x4)
|
||||
|
||||
let entityIdCounter = 100;
|
||||
|
||||
// Sala inicial (siempre en 0,0 con el héroe)
|
||||
rooms.push({
|
||||
id: 1,
|
||||
tile: { type: 'tile_base', x: 0, y: 0 },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [],
|
||||
entities: [{ id: entityIdCounter++, type: 'hero_1', x: 1, y: 1 }]
|
||||
});
|
||||
|
||||
// Direcciones posibles: N, S, E, W
|
||||
const directions = [
|
||||
{ side: 'N', dx: 0, dy: -4, opposite: 'S' },
|
||||
{ side: 'S', dx: 0, dy: 4, opposite: 'N' },
|
||||
{ side: 'E', dx: 4, dy: 0, opposite: 'W' },
|
||||
{ side: 'W', dx: -4, dy: 0, opposite: 'E' }
|
||||
];
|
||||
|
||||
// Posiciones ocupadas (para evitar solapamientos)
|
||||
const occupied = new Set(['0,0']);
|
||||
|
||||
// Cola de salas pendientes de expandir
|
||||
const queue = [{ roomId: 1, x: 0, y: 0 }];
|
||||
|
||||
while (rooms.length < maxRooms && queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const currentRoom = rooms.find(r => r.id === current.roomId);
|
||||
|
||||
// Intentar añadir salas en direcciones aleatorias
|
||||
const shuffledDirections = [...directions].sort(() => Math.random() - 0.5);
|
||||
|
||||
for (const dir of shuffledDirections) {
|
||||
if (rooms.length >= maxRooms) break;
|
||||
|
||||
const newX = current.x + dir.dx;
|
||||
const newY = current.y + dir.dy;
|
||||
const posKey = `${newX},${newY}`;
|
||||
|
||||
// Verificar que no esté ocupada
|
||||
if (occupied.has(posKey)) continue;
|
||||
|
||||
// 60% de probabilidad de crear sala en esta dirección (aumentado para más conectividad)
|
||||
if (Math.random() < 0.4) continue;
|
||||
|
||||
// Crear nueva sala
|
||||
const newRoomId = rooms.length + 1;
|
||||
const tileType = tileTypes[Math.floor(Math.random() * tileTypes.length)];
|
||||
|
||||
// Generar 0, 1 o 2 esqueletos aleatorios
|
||||
const numSkeletons = Math.floor(Math.random() * 3); // 0, 1, o 2
|
||||
const newEntities = [];
|
||||
|
||||
for (let i = 0; i < numSkeletons; i++) {
|
||||
// Posición aleatoria dentro de la tile 4x4
|
||||
const randomX = newX + Math.floor(Math.random() * 4);
|
||||
const randomY = newY + Math.floor(Math.random() * 4);
|
||||
|
||||
newEntities.push({
|
||||
id: entityIdCounter++,
|
||||
type: 'hero_2', // esqueleto
|
||||
x: randomX,
|
||||
y: randomY
|
||||
});
|
||||
}
|
||||
|
||||
const newRoom = {
|
||||
id: newRoomId,
|
||||
tile: { type: tileType, x: newX, y: newY },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [],
|
||||
entities: newEntities
|
||||
};
|
||||
|
||||
// Añadir la sala primero
|
||||
rooms.push(newRoom);
|
||||
occupied.add(posKey);
|
||||
queue.push({ roomId: newRoomId, x: newX, y: newY });
|
||||
|
||||
// AHORA crear puertas (solo si la sala fue creada)
|
||||
const doorGridPos = Math.floor(Math.random() * 2) + 1; // Posición 1 o 2
|
||||
const doorConfig = dir.side === 'N' || dir.side === 'S'
|
||||
? { side: dir.side, gridX: doorGridPos, leadsTo: newRoomId }
|
||||
: { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId };
|
||||
|
||||
currentRoom.doors.push(doorConfig);
|
||||
|
||||
// Crear puerta en la nueva sala hacia la actual
|
||||
const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S'
|
||||
? { side: dir.opposite, gridX: doorGridPos, leadsTo: current.roomId }
|
||||
: { side: dir.opposite, gridY: doorGridPos, leadsTo: current.roomId };
|
||||
|
||||
newRoom.doors.push(oppositeDoorConfig);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rooms: rooms,
|
||||
visitedRooms: new Set([1]),
|
||||
currentRoom: 1
|
||||
};
|
||||
}
|
||||
|
||||
const ROOMS = generateDungeon();
|
||||
|
||||
// State de la sesión (UI)
|
||||
const SESSION = {
|
||||
selectedUnitId: null,
|
||||
path: [], // Array de {x, y}
|
||||
pathMeshes: [], // Array de meshes visuales
|
||||
isAnimating: false // Flag para bloquear interacciones durante animación
|
||||
roomMeshes: {}, // { roomId: { tile: mesh, walls: [], doors: [], entities: [] } }
|
||||
isAnimating: false,
|
||||
textureCache: {}, // Cache de texturas cargadas
|
||||
currentView: 'N' // Vista actual: N, S, E, W
|
||||
};
|
||||
|
||||
// --- CONFIGURACIÓN BÁSICA THREE.JS ---
|
||||
@@ -51,28 +155,46 @@ renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.shadowMap.enabled = true;
|
||||
document.querySelector('#app').appendChild(renderer.domElement);
|
||||
|
||||
// Cámara
|
||||
// Cámara isométrica (zoom más cercano)
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
const d = 15;
|
||||
const d = 8; // Reducido de 10 a 8 para zoom aún más cercano
|
||||
const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
|
||||
camera.position.set(20, 20, 20);
|
||||
camera.lookAt(scene.position);
|
||||
|
||||
// --- CONTROLES MODIFICADOS ---
|
||||
// Roto con el ratón derecho, zoom con la rueda del ratón y si hago presión en la rueda, hago el paneo.
|
||||
// Vistas isométricas predefinidas
|
||||
const CAMERA_VIEWS = {
|
||||
N: { position: { x: 20, y: 20, z: 20 }, target: { x: 0, y: 0, z: 0 } }, // Norte (default)
|
||||
S: { position: { x: -20, y: 20, z: -20 }, target: { x: 0, y: 0, z: 0 } }, // Sur
|
||||
E: { position: { x: -20, y: 20, z: 20 }, target: { x: 0, y: 0, z: 0 } }, // Este
|
||||
W: { position: { x: 20, y: 20, z: -20 }, target: { x: 0, y: 0, z: 0 } } // Oeste
|
||||
};
|
||||
|
||||
// OrbitControls solo para zoom y paneo (sin rotación)
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableRotate = false; // Deshabilitar rotación
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.maxPolarAngle = Math.PI / 2;
|
||||
|
||||
// Reasignación de botones
|
||||
controls.mouseButtons = {
|
||||
LEFT: null, // Dejamos el click izquierdo libre para nuestra lógica
|
||||
MIDDLE: THREE.MOUSE.PAN, // Paneo con botón central/rueda
|
||||
RIGHT: THREE.MOUSE.ROTATE // Rotación con derecho
|
||||
LEFT: null, // Click izquierdo libre para selección
|
||||
MIDDLE: THREE.MOUSE.PAN, // Paneo con botón central
|
||||
RIGHT: THREE.MOUSE.PAN // Paneo también con botón derecho
|
||||
};
|
||||
controls.zoomToCursor = true; // Zoom a donde apunta el ratón
|
||||
controls.zoomToCursor = true;
|
||||
controls.minZoom = 0.5;
|
||||
controls.maxZoom = 3;
|
||||
|
||||
function setCameraView(direction) {
|
||||
const view = CAMERA_VIEWS[direction];
|
||||
camera.position.set(view.position.x, view.position.y, view.position.z);
|
||||
camera.lookAt(view.target.x, view.target.y, view.target.z);
|
||||
controls.target.set(view.target.x, view.target.y, view.target.z);
|
||||
controls.update();
|
||||
SESSION.currentView = direction;
|
||||
updateCompassUI();
|
||||
}
|
||||
|
||||
// Establecer vista inicial
|
||||
setCameraView('N');
|
||||
|
||||
// Luces
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
@@ -86,14 +208,13 @@ scene.add(dirLight);
|
||||
const gridHelper = new THREE.GridHelper(40, 40, 0x444444, 0x111111);
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Plano invisible para Raycasting en Y=0
|
||||
// Plano invisible para Raycasting
|
||||
const planeGeometry = new THREE.PlaneGeometry(200, 200);
|
||||
const planeMaterial = new THREE.MeshBasicMaterial({ visible: false });
|
||||
const raycastPlane = new THREE.Mesh(planeGeometry, planeMaterial);
|
||||
raycastPlane.rotation.x = -Math.PI / 2;
|
||||
scene.add(raycastPlane);
|
||||
|
||||
|
||||
// --- HELPERS LÓGICOS ---
|
||||
function worldToGrid(x, z) {
|
||||
return {
|
||||
@@ -112,27 +233,67 @@ function gridToWorld(gridX, gridY) {
|
||||
function isAdjacent(p1, p2) {
|
||||
const dx = Math.abs(p1.x - p2.x);
|
||||
const dy = Math.abs(p1.y - p2.y);
|
||||
// Adyacencia ortogonal (cruz)
|
||||
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
|
||||
}
|
||||
|
||||
// --- CREACIÓN DE MARCADORES (CANVAS TEXTURE) ---
|
||||
// Verificar si una posición está dentro de una sala
|
||||
function isPositionInRoom(x, y, room) {
|
||||
const tile = room.tile;
|
||||
const tileDef = ASSETS.tiles[tile.type];
|
||||
const minX = tile.x;
|
||||
const maxX = tile.x + tileDef.width - 1;
|
||||
const minY = tile.y;
|
||||
const maxY = tile.y + tileDef.height - 1;
|
||||
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
// Verificar si una posición es una puerta
|
||||
function isPositionDoor(x, y, room) {
|
||||
for (const door of room.doors) {
|
||||
const doorPos = getDoorGridPosition(room, door);
|
||||
if (doorPos.x === x && doorPos.y === y) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar si una celda es transitable
|
||||
function isWalkable(x, y) {
|
||||
// Verificar en todas las salas visitadas
|
||||
for (const roomId of ROOMS.visitedRooms) {
|
||||
const room = ROOMS.rooms.find(r => r.id === roomId);
|
||||
if (!room) continue;
|
||||
|
||||
// Si está dentro de la sala, es transitable
|
||||
if (isPositionInRoom(x, y, room)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si es una puerta de la sala, es transitable
|
||||
if (isPositionDoor(x, y, room)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- CREACIÓN DE MARCADORES ---
|
||||
function createPathMarker(stepNumber) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Fondo Amarillo Semi-transparente
|
||||
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
|
||||
// Borde
|
||||
ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)';
|
||||
ctx.lineWidth = 10;
|
||||
ctx.strokeRect(0, 0, 128, 128);
|
||||
|
||||
// Número
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = 'bold 60px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
@@ -140,29 +301,25 @@ function createPathMarker(stepNumber) {
|
||||
ctx.fillText(stepNumber.toString(), 64, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
// Importante para pixel art o gráficos nítidos, aunque aquí es texto
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
|
||||
// Crear el mesh
|
||||
const geometry = new THREE.PlaneGeometry(CONFIG.CELL_SIZE * 0.9, CONFIG.CELL_SIZE * 0.9);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide // Visible desde ambos lados
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.y = 0.05; // Ligeramente elevado sobre el suelo
|
||||
mesh.position.y = 0.05;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function updatePathVisuals() {
|
||||
// 1. Limpiar anteriores
|
||||
SESSION.pathMeshes.forEach(mesh => scene.remove(mesh));
|
||||
SESSION.pathMeshes = [];
|
||||
|
||||
// 2. Crear nuevos
|
||||
SESSION.path.forEach((pos, index) => {
|
||||
const marker = createPathMarker(index + 1);
|
||||
const worldPos = gridToWorld(pos.x, pos.y);
|
||||
@@ -175,19 +332,23 @@ function updatePathVisuals() {
|
||||
|
||||
// --- MANEJO VISUAL DE SELECCIÓN ---
|
||||
function updateSelectionVisuals() {
|
||||
GAME_STATE.entities.forEach(entity => {
|
||||
if (!entity.mesh) return;
|
||||
// Buscar en todas las salas visitadas
|
||||
ROOMS.visitedRooms.forEach(roomId => {
|
||||
const room = ROOMS.rooms.find(r => r.id === roomId);
|
||||
if (!room) return;
|
||||
|
||||
if (entity.id === SESSION.selectedUnitId) {
|
||||
// SELECCIONADO: Amarillo + Opacidad 50%
|
||||
entity.mesh.material.color.setHex(0xffff00);
|
||||
entity.mesh.material.opacity = 0.5;
|
||||
entity.mesh.material.transparent = true;
|
||||
} else {
|
||||
// NO SELECCIONADO: Blanco (color original) + Opacidad 100%
|
||||
entity.mesh.material.color.setHex(0xffffff);
|
||||
entity.mesh.material.opacity = 1.0;
|
||||
}
|
||||
room.entities.forEach(entity => {
|
||||
if (!entity.mesh) return;
|
||||
|
||||
if (entity.id === SESSION.selectedUnitId) {
|
||||
entity.mesh.material.color.setHex(0xffff00);
|
||||
entity.mesh.material.opacity = 0.5;
|
||||
entity.mesh.material.transparent = true;
|
||||
} else {
|
||||
entity.mesh.material.color.setHex(0xffffff);
|
||||
entity.mesh.material.opacity = 1.0;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,23 +358,31 @@ async function animateMovement() {
|
||||
|
||||
SESSION.isAnimating = true;
|
||||
|
||||
const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId);
|
||||
// Buscar la entidad en todas las salas
|
||||
let unit = null;
|
||||
let unitRoom = null;
|
||||
for (const room of ROOMS.rooms) {
|
||||
unit = room.entities.find(e => e.id === SESSION.selectedUnitId);
|
||||
if (unit) {
|
||||
unitRoom = room;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!unit || !unit.mesh) {
|
||||
SESSION.isAnimating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Copiar el path para ir consumiéndolo
|
||||
const pathCopy = [...SESSION.path];
|
||||
|
||||
// Función helper para animar un solo paso
|
||||
const animateStep = (targetGridPos) => {
|
||||
return new Promise((resolve) => {
|
||||
const startPos = { x: unit.mesh.position.x, z: unit.mesh.position.z };
|
||||
const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y);
|
||||
const endPos = { x: targetWorldPos.x, z: targetWorldPos.z };
|
||||
|
||||
const duration = 300; // ms por paso
|
||||
const duration = 300;
|
||||
const startTime = Date.now();
|
||||
const standeeHeight = ASSETS.standees[unit.type].height;
|
||||
|
||||
@@ -221,24 +390,20 @@ async function animateMovement() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing suave (ease-in-out)
|
||||
const eased = progress < 0.5
|
||||
? 2 * progress * progress
|
||||
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
||||
|
||||
// Interpolación lineal en X y Z
|
||||
unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased;
|
||||
unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased;
|
||||
|
||||
// Saltito parabólico en Y
|
||||
const hopHeight = 0.8; // Altura del salto
|
||||
const hopProgress = Math.sin(progress * Math.PI); // 0 -> 1 -> 0
|
||||
const hopHeight = 0.8;
|
||||
const hopProgress = Math.sin(progress * Math.PI);
|
||||
unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(hop);
|
||||
} else {
|
||||
// Asegurar posición final exacta
|
||||
unit.mesh.position.x = endPos.x;
|
||||
unit.mesh.position.z = endPos.z;
|
||||
unit.mesh.position.y = standeeHeight / 2;
|
||||
@@ -250,65 +415,356 @@ async function animateMovement() {
|
||||
});
|
||||
};
|
||||
|
||||
// Mover paso a paso
|
||||
for (let i = 0; i < pathCopy.length; i++) {
|
||||
const step = pathCopy[i];
|
||||
|
||||
// Animar el movimiento
|
||||
await animateStep(step);
|
||||
|
||||
// Actualizar posición lógica de la unidad
|
||||
unit.x = step.x;
|
||||
unit.y = step.y;
|
||||
|
||||
// Borrar el marcador de esta celda (el primero del array)
|
||||
// Verificar si hemos llegado a una puerta
|
||||
checkDoorTransition(unit, unitRoom);
|
||||
|
||||
SESSION.path.shift();
|
||||
updatePathVisuals();
|
||||
}
|
||||
|
||||
// Al terminar, deseleccionar
|
||||
// Centrar cámara en el personaje manteniendo el offset de la vista actual
|
||||
const newTarget = unit.mesh.position.clone();
|
||||
newTarget.y = 0;
|
||||
|
||||
const currentOffset = camera.position.clone().sub(controls.target);
|
||||
|
||||
controls.target.copy(newTarget);
|
||||
camera.position.copy(newTarget).add(currentOffset);
|
||||
|
||||
// Actualizar el target de la vista actual para futuras referencias
|
||||
CAMERA_VIEWS[SESSION.currentView].target = { x: newTarget.x, y: newTarget.y, z: newTarget.z };
|
||||
|
||||
|
||||
SESSION.selectedUnitId = null;
|
||||
updateSelectionVisuals();
|
||||
SESSION.isAnimating = false;
|
||||
}
|
||||
|
||||
// --- VERIFICAR TRANSICIÓN DE PUERTA ---
|
||||
function checkDoorTransition(unit, currentRoom) {
|
||||
for (const door of currentRoom.doors) {
|
||||
const doorGridPos = getDoorGridPosition(currentRoom, door);
|
||||
|
||||
if (unit.x === doorGridPos.x && unit.y === doorGridPos.y) {
|
||||
const targetRoomId = door.leadsTo;
|
||||
|
||||
if (!ROOMS.visitedRooms.has(targetRoomId)) {
|
||||
ROOMS.visitedRooms.add(targetRoomId);
|
||||
const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId);
|
||||
if (targetRoom) {
|
||||
renderRoom(targetRoom);
|
||||
}
|
||||
}
|
||||
|
||||
// Mover entidad a la nueva sala
|
||||
const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId);
|
||||
if (targetRoom) {
|
||||
const entityIndex = currentRoom.entities.indexOf(unit);
|
||||
if (entityIndex > -1) {
|
||||
currentRoom.entities.splice(entityIndex, 1);
|
||||
|
||||
// Actualizar tracking de meshes
|
||||
if (SESSION.roomMeshes[currentRoom.id]) {
|
||||
const meshIndex = SESSION.roomMeshes[currentRoom.id].entities.indexOf(unit.mesh);
|
||||
if (meshIndex > -1) {
|
||||
SESSION.roomMeshes[currentRoom.id].entities.splice(meshIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetRoom.entities.push(unit);
|
||||
|
||||
// Añadir mesh al tracking de la nueva sala
|
||||
if (SESSION.roomMeshes[targetRoomId]) {
|
||||
SESSION.roomMeshes[targetRoomId].entities.push(unit.mesh);
|
||||
}
|
||||
|
||||
ROOMS.currentRoom = targetRoomId;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDoorGridPosition(room, door) {
|
||||
const tile = room.tile;
|
||||
const tileWidth = ASSETS.tiles[tile.type].width;
|
||||
const tileHeight = ASSETS.tiles[tile.type].height;
|
||||
|
||||
switch (door.side) {
|
||||
case 'N':
|
||||
return { x: tile.x + door.gridX, y: tile.y - 1 };
|
||||
case 'S':
|
||||
return { x: tile.x + door.gridX, y: tile.y + tileHeight };
|
||||
case 'E':
|
||||
return { x: tile.x + tileWidth, y: tile.y + door.gridY };
|
||||
case 'W':
|
||||
return { x: tile.x - 1, y: tile.y + door.gridY };
|
||||
}
|
||||
}
|
||||
|
||||
// --- CARGA Y RENDERIZADO ---
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
|
||||
function loadTexture(path) {
|
||||
if (SESSION.textureCache[path]) {
|
||||
return Promise.resolve(SESSION.textureCache[path]);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
textureLoader.load(path, (tex) => {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
SESSION.textureCache[path] = tex;
|
||||
resolve(tex);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderRoom(room) {
|
||||
if (SESSION.roomMeshes[room.id]) {
|
||||
return; // Ya renderizada
|
||||
}
|
||||
|
||||
const roomMeshes = {
|
||||
tile: null,
|
||||
walls: [],
|
||||
doors: [],
|
||||
entities: []
|
||||
};
|
||||
|
||||
// Renderizar tile
|
||||
const tileDef = ASSETS.tiles[room.tile.type];
|
||||
const tileTex = await loadTexture(tileDef.src);
|
||||
tileTex.wrapS = THREE.RepeatWrapping;
|
||||
tileTex.wrapT = THREE.RepeatWrapping;
|
||||
tileTex.repeat.set(tileDef.width / 2, tileDef.height / 2);
|
||||
|
||||
const worldWidth = tileDef.width * CONFIG.CELL_SIZE;
|
||||
const worldHeight = tileDef.height * CONFIG.CELL_SIZE;
|
||||
|
||||
const tileGeometry = new THREE.PlaneGeometry(worldWidth, worldHeight);
|
||||
const tileMaterial = new THREE.MeshStandardMaterial({
|
||||
map: tileTex,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const tileMesh = new THREE.Mesh(tileGeometry, tileMaterial);
|
||||
|
||||
tileMesh.rotation.x = -Math.PI / 2;
|
||||
tileMesh.receiveShadow = true;
|
||||
|
||||
const originPos = gridToWorld(room.tile.x, room.tile.y);
|
||||
tileMesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
tileMesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
tileMesh.position.y = 0;
|
||||
|
||||
scene.add(tileMesh);
|
||||
roomMeshes.tile = tileMesh;
|
||||
|
||||
// Renderizar paredes
|
||||
const wallTex = await loadTexture(ASSETS.tiles['wall_1'].src);
|
||||
wallTex.wrapS = THREE.RepeatWrapping;
|
||||
wallTex.wrapT = THREE.RepeatWrapping;
|
||||
wallTex.repeat.set(2, 2);
|
||||
|
||||
const wallHeight = 2.5;
|
||||
const halfSizeX = worldWidth / 2;
|
||||
const halfSizeZ = worldHeight / 2;
|
||||
const centerX = tileMesh.position.x;
|
||||
const centerZ = tileMesh.position.z;
|
||||
|
||||
const wallGeometry = new THREE.PlaneGeometry(worldWidth, wallHeight);
|
||||
|
||||
const wallConfigs = [
|
||||
{ side: 'N', offset: { x: 0, z: -halfSizeZ }, rotation: 0, opacity: 1.0 },
|
||||
{ side: 'S', offset: { x: 0, z: halfSizeZ }, rotation: 0, opacity: 0.5 },
|
||||
{ side: 'E', offset: { x: halfSizeX, z: 0 }, rotation: Math.PI / 2, opacity: 0.5 },
|
||||
{ side: 'W', offset: { x: -halfSizeX, z: 0 }, rotation: Math.PI / 2, opacity: 1.0 }
|
||||
];
|
||||
|
||||
for (const config of wallConfigs) {
|
||||
if (room.walls.includes(config.side)) {
|
||||
const wallMaterial = new THREE.MeshStandardMaterial({
|
||||
map: wallTex.clone(),
|
||||
transparent: config.opacity < 1.0,
|
||||
opacity: config.opacity,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const wall = new THREE.Mesh(wallGeometry, wallMaterial);
|
||||
wall.position.set(
|
||||
centerX + config.offset.x,
|
||||
wallHeight / 2,
|
||||
centerZ + config.offset.z
|
||||
);
|
||||
wall.rotation.y = config.rotation;
|
||||
wall.castShadow = true;
|
||||
wall.receiveShadow = true;
|
||||
scene.add(wall);
|
||||
roomMeshes.walls.push(wall);
|
||||
}
|
||||
}
|
||||
|
||||
// Renderizar puertas
|
||||
const doorTex = await loadTexture(ASSETS.tiles['door_1'].src);
|
||||
const doorWidth = 1.5;
|
||||
const doorHeight = 2.0;
|
||||
|
||||
for (const door of room.doors) {
|
||||
const doorGeometry = new THREE.PlaneGeometry(doorWidth, doorHeight);
|
||||
const doorMaterial = new THREE.MeshStandardMaterial({
|
||||
map: doorTex.clone(),
|
||||
transparent: true,
|
||||
alphaTest: 0.1,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const doorMesh = new THREE.Mesh(doorGeometry, doorMaterial);
|
||||
const doorGridPos = getDoorGridPosition(room, door);
|
||||
const doorWorldPos = gridToWorld(doorGridPos.x, doorGridPos.y);
|
||||
|
||||
switch (door.side) {
|
||||
case 'N':
|
||||
doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ - halfSizeZ + 0.05);
|
||||
doorMesh.rotation.y = 0;
|
||||
break;
|
||||
case 'S':
|
||||
doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ + halfSizeZ - 0.05);
|
||||
doorMesh.rotation.y = 0;
|
||||
break;
|
||||
case 'E':
|
||||
doorMesh.position.set(centerX + halfSizeX - 0.05, doorHeight / 2, doorWorldPos.z);
|
||||
doorMesh.rotation.y = Math.PI / 2;
|
||||
break;
|
||||
case 'W':
|
||||
doorMesh.position.set(centerX - halfSizeX + 0.05, doorHeight / 2, doorWorldPos.z);
|
||||
doorMesh.rotation.y = Math.PI / 2;
|
||||
break;
|
||||
}
|
||||
|
||||
scene.add(doorMesh);
|
||||
roomMeshes.doors.push(doorMesh);
|
||||
}
|
||||
|
||||
// Renderizar entidades
|
||||
for (const entity of room.entities) {
|
||||
// Si la entidad ya tiene mesh (vino de otra sala), solo añadirlo al tracking
|
||||
if (entity.mesh) {
|
||||
roomMeshes.entities.push(entity.mesh);
|
||||
continue;
|
||||
}
|
||||
|
||||
const standeeDef = ASSETS.standees[entity.type];
|
||||
const standeeTex = await loadTexture(standeeDef.src);
|
||||
|
||||
const imgAspect = standeeTex.image.width / standeeTex.image.height;
|
||||
const height = standeeDef.height;
|
||||
const width = height * imgAspect;
|
||||
|
||||
const standeeGeometry = new THREE.PlaneGeometry(width, height);
|
||||
const standeeMaterial = new THREE.MeshStandardMaterial({
|
||||
map: standeeTex,
|
||||
transparent: true,
|
||||
alphaTest: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const standeeMesh = new THREE.Mesh(standeeGeometry, standeeMaterial);
|
||||
standeeMesh.castShadow = true;
|
||||
|
||||
const pos = gridToWorld(entity.x, entity.y);
|
||||
standeeMesh.position.set(pos.x, height / 2, pos.z);
|
||||
|
||||
scene.add(standeeMesh);
|
||||
entity.mesh = standeeMesh;
|
||||
roomMeshes.entities.push(standeeMesh);
|
||||
}
|
||||
|
||||
SESSION.roomMeshes[room.id] = roomMeshes;
|
||||
}
|
||||
|
||||
async function initWorld() {
|
||||
// Renderizar solo las salas visitadas
|
||||
for (const roomId of ROOMS.visitedRooms) {
|
||||
const room = ROOMS.rooms.find(r => r.id === roomId);
|
||||
if (room) {
|
||||
await renderRoom(room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initWorld();
|
||||
|
||||
// --- COMPASS UI ---
|
||||
function updateCompassUI() {
|
||||
document.querySelectorAll('.compass-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
const activeBtn = document.querySelector(`[data-direction="${SESSION.currentView}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners para los botones del compás
|
||||
document.querySelectorAll('.compass-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const direction = btn.getAttribute('data-direction');
|
||||
setCameraView(direction);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// --- INTERACCIÓN ---
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
|
||||
window.addEventListener('pointerdown', (event) => {
|
||||
// Bloquear interacciones durante animación
|
||||
if (SESSION.isAnimating) return;
|
||||
|
||||
// CLICK IZQUIERDO: Selección y Pathfinding
|
||||
if (event.button === 0) {
|
||||
|
||||
// Calcular coordenadas normalizadas (-1 a +1)
|
||||
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||||
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
|
||||
// 1. Detectar Click en Entidades (Selección)
|
||||
// Buscamos intersecciones con los meshes de las entidades
|
||||
const entityMeshes = GAME_STATE.entities.map(e => e.mesh).filter(m => m);
|
||||
// Detectar click en entidades
|
||||
const allEntities = [];
|
||||
ROOMS.visitedRooms.forEach(roomId => {
|
||||
const room = ROOMS.rooms.find(r => r.id === roomId);
|
||||
if (room) {
|
||||
allEntities.push(...room.entities.filter(e => e.mesh));
|
||||
}
|
||||
});
|
||||
|
||||
const entityMeshes = allEntities.map(e => e.mesh);
|
||||
const intersectsEntities = raycaster.intersectObjects(entityMeshes);
|
||||
|
||||
if (intersectsEntities.length > 0) {
|
||||
// Hemos clickado una entidad
|
||||
const clickedMesh = intersectsEntities[0].object;
|
||||
const entity = GAME_STATE.entities.find(e => e.mesh === clickedMesh);
|
||||
const entity = allEntities.find(e => e.mesh === clickedMesh);
|
||||
if (entity) {
|
||||
console.log("Seleccionado:", entity.type);
|
||||
SESSION.selectedUnitId = entity.id;
|
||||
SESSION.path = []; // Resetear camino
|
||||
SESSION.path = [];
|
||||
updatePathVisuals();
|
||||
updateSelectionVisuals(); // Actualizar color del standee
|
||||
return; // Cortamos aquí para no procesar click de suelo a la vez
|
||||
updateSelectionVisuals();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si hay unidad seleccionada, procesar Click en Suelo (Move)
|
||||
// Procesar click en suelo
|
||||
if (SESSION.selectedUnitId) {
|
||||
const intersectsGround = raycaster.intersectObject(raycastPlane);
|
||||
|
||||
@@ -316,34 +772,29 @@ window.addEventListener('pointerdown', (event) => {
|
||||
const point = intersectsGround[0].point;
|
||||
const gridPos = worldToGrid(point.x, point.z);
|
||||
|
||||
// LOGICA DEL PATHFINDING MANUAL
|
||||
|
||||
// Punto de Origen: La última casilla del path, O la casilla de la unidad si empieza
|
||||
let prevNode;
|
||||
if (SESSION.path.length > 0) {
|
||||
prevNode = SESSION.path[SESSION.path.length - 1];
|
||||
} else {
|
||||
const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId);
|
||||
prevNode = { x: unit.x, y: unit.y };
|
||||
const entity = allEntities.find(e => e.id === SESSION.selectedUnitId);
|
||||
prevNode = { x: entity.x, y: entity.y };
|
||||
}
|
||||
|
||||
// A. Caso Deshacer (Click en la última)
|
||||
if (SESSION.path.length > 0) {
|
||||
const lastNode = SESSION.path[SESSION.path.length - 1];
|
||||
if (lastNode.x === gridPos.x && lastNode.y === gridPos.y) {
|
||||
SESSION.path.pop(); // Borrar último
|
||||
SESSION.path.pop();
|
||||
updatePathVisuals();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// B. Caso Añadir (Tiene que ser adyacente al anterior)
|
||||
if (isAdjacent(prevNode, gridPos)) {
|
||||
// Comprobación opcional: Evitar bucles (no clickar en uno que ya está en el path)
|
||||
const alreadyInPath = SESSION.path.some(p => p.x === gridPos.x && p.y === gridPos.y);
|
||||
const isUnitPos = (gridPos.x === prevNode.x && gridPos.y === prevNode.y && SESSION.path.length === 0);
|
||||
|
||||
if (!alreadyInPath && !isUnitPos) {
|
||||
// VALIDACIÓN: Solo permitir movimiento a celdas transitables
|
||||
if (!alreadyInPath && !isUnitPos && isWalkable(gridPos.x, gridPos.y)) {
|
||||
SESSION.path.push(gridPos);
|
||||
updatePathVisuals();
|
||||
}
|
||||
@@ -352,9 +803,8 @@ window.addEventListener('pointerdown', (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// CLICK DERECHO: Ejecutar movimiento
|
||||
if (event.button === 2) {
|
||||
event.preventDefault(); // Evitar menú contextual
|
||||
event.preventDefault();
|
||||
|
||||
if (SESSION.selectedUnitId && SESSION.path.length > 0) {
|
||||
animateMovement();
|
||||
@@ -362,104 +812,13 @@ window.addEventListener('pointerdown', (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Prevenir menú contextual del navegador
|
||||
window.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
// --- CARGA Y RENDERIZADO ---
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
|
||||
function loadTexture(path) {
|
||||
return new Promise((resolve) => {
|
||||
textureLoader.load(path, (tex) => {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
resolve(tex);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initWorld() {
|
||||
const tileTextures = {};
|
||||
const standeeTextures = {};
|
||||
|
||||
// Cargar Tiles
|
||||
for (const [key, def] of Object.entries(ASSETS.tiles)) {
|
||||
tileTextures[key] = await loadTexture(def.src);
|
||||
}
|
||||
// Cargar Standees
|
||||
for (const [key, def] of Object.entries(ASSETS.standees)) {
|
||||
standeeTextures[key] = await loadTexture(def.src);
|
||||
}
|
||||
|
||||
// Instanciar Tiles (Suelo)
|
||||
GAME_STATE.placedTiles.forEach(tileData => {
|
||||
const def = ASSETS.tiles[tileData.type];
|
||||
const tex = tileTextures[tileData.type];
|
||||
const worldWidth = def.width * CONFIG.CELL_SIZE;
|
||||
const worldHeight = def.height * CONFIG.CELL_SIZE;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(worldWidth, worldHeight);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
const originPos = gridToWorld(tileData.x, tileData.y);
|
||||
|
||||
// Ajuste de centro
|
||||
mesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
mesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
mesh.position.y = 0;
|
||||
|
||||
scene.add(mesh);
|
||||
});
|
||||
|
||||
// Instanciar Entidades
|
||||
GAME_STATE.entities.forEach(entity => {
|
||||
const def = ASSETS.standees[entity.type];
|
||||
const tex = standeeTextures[entity.type];
|
||||
|
||||
const imgAspect = tex.image.width / tex.image.height;
|
||||
const height = def.height;
|
||||
const width = height * imgAspect;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(width, height);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
alphaTest: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
|
||||
const pos = gridToWorld(entity.x, entity.y);
|
||||
mesh.position.set(pos.x, height / 2, pos.z);
|
||||
|
||||
scene.add(mesh);
|
||||
entity.mesh = mesh;
|
||||
});
|
||||
}
|
||||
|
||||
initWorld();
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
|
||||
// Billboard opcional para los marcadores de texto?
|
||||
// No, los queremos pegados al suelo según la spec.
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
596
src/main_old.js
Normal file
596
src/main_old.js
Normal file
@@ -0,0 +1,596 @@
|
||||
import './style.css';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
|
||||
// --- CONFIGURACIÓN DE LA ESCENA ---
|
||||
const CONFIG = {
|
||||
CELL_SIZE: 2, // Unidades de Three.js por celda lógica
|
||||
TILE_DIMENSIONS: 4, // Una tile es de 4x4 celdas
|
||||
};
|
||||
|
||||
// --- ESTADO DEL JUEGO (DATA MODEL) ---
|
||||
const ASSETS = {
|
||||
tiles: {
|
||||
'tile_base': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 4 },
|
||||
'tile_cyan': { src: '/assets/images/tiles/tile4x4_blue.png', width: 4, height: 4 },
|
||||
'tile_orange': { src: '/assets/images/tiles/tile4x4_orange.png', width: 4, height: 4 },
|
||||
'tile_8x2': { src: '/assets/images/tiles/tile4x4.png', width: 8, height: 2 },
|
||||
'wall_1': { src: '/assets/images/tiles/pared1.png' },
|
||||
'door_1': { src: '/assets/images/tiles/puerta1.png' },
|
||||
},
|
||||
standees: {
|
||||
'hero_1': { src: '/assets/images/standees/barbaro.png', height: 3 },
|
||||
'hero_2': { src: '/assets/images/standees/esqueleto.png', height: 3 },
|
||||
}
|
||||
};
|
||||
|
||||
// Sistema de salas
|
||||
const ROOMS = {
|
||||
rooms: [
|
||||
{
|
||||
id: 1,
|
||||
tile: { type: 'tile_base', x: 0, y: 0 },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [
|
||||
{ side: 'N', gridPos: { x: 1, y: -1 }, leadsTo: 2 }
|
||||
],
|
||||
entities: [{ id: 101, type: 'hero_1', x: 1, y: 1 }]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tile: { type: 'tile_cyan', x: 0, y: -4 },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [
|
||||
{ side: 'S', gridPos: { x: 1, y: -1 }, leadsTo: 1 }
|
||||
],
|
||||
entities: [{ id: 102, type: 'hero_2', x: 1, y: -5 }]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tile: { type: 'tile_orange', x: -4, y: 0 },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [
|
||||
{ side: 'E', gridPos: { x: -1, y: 1 }, leadsTo: 1 }
|
||||
],
|
||||
entities: []
|
||||
}
|
||||
],
|
||||
visitedRooms: [1], // Empezamos en la sala 1
|
||||
currentRoom: 1
|
||||
};
|
||||
|
||||
const GAME_STATE = {
|
||||
placedTiles: [],
|
||||
entities: []
|
||||
};
|
||||
|
||||
// State de la sesión (UI)
|
||||
const SESSION = {
|
||||
selectedUnitId: null,
|
||||
path: [], // Array de {x, y}
|
||||
pathMeshes: [], // Array de meshes visuales
|
||||
roomMeshes: {}, // { roomId: { tile: mesh, walls: [], doors: [], entities: [] } }
|
||||
isAnimating: false // Flag para bloquear interacciones durante animación
|
||||
};
|
||||
|
||||
// --- CONFIGURACIÓN BÁSICA THREE.JS ---
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x202020);
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.shadowMap.enabled = true;
|
||||
document.querySelector('#app').appendChild(renderer.domElement);
|
||||
|
||||
// Cámara
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
const d = 15;
|
||||
const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
|
||||
camera.position.set(20, 20, 20);
|
||||
camera.lookAt(scene.position);
|
||||
|
||||
// --- CONTROLES MODIFICADOS ---
|
||||
// Roto con el ratón derecho, zoom con la rueda del ratón y si hago presión en la rueda, hago el paneo.
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.maxPolarAngle = Math.PI / 2;
|
||||
|
||||
// Reasignación de botones
|
||||
controls.mouseButtons = {
|
||||
LEFT: null, // Dejamos el click izquierdo libre para nuestra lógica
|
||||
MIDDLE: THREE.MOUSE.PAN, // Paneo con botón central/rueda
|
||||
RIGHT: THREE.MOUSE.ROTATE // Rotación con derecho
|
||||
};
|
||||
controls.zoomToCursor = true; // Zoom a donde apunta el ratón
|
||||
|
||||
// Luces
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight.position.set(10, 20, 5);
|
||||
dirLight.castShadow = true;
|
||||
scene.add(dirLight);
|
||||
|
||||
const gridHelper = new THREE.GridHelper(40, 40, 0x444444, 0x111111);
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Plano invisible para Raycasting en Y=0
|
||||
const planeGeometry = new THREE.PlaneGeometry(200, 200);
|
||||
const planeMaterial = new THREE.MeshBasicMaterial({ visible: false });
|
||||
const raycastPlane = new THREE.Mesh(planeGeometry, planeMaterial);
|
||||
raycastPlane.rotation.x = -Math.PI / 2;
|
||||
scene.add(raycastPlane);
|
||||
|
||||
|
||||
// --- HELPERS LÓGICOS ---
|
||||
function worldToGrid(x, z) {
|
||||
return {
|
||||
x: Math.floor(x / CONFIG.CELL_SIZE),
|
||||
y: Math.floor(z / CONFIG.CELL_SIZE)
|
||||
};
|
||||
}
|
||||
|
||||
function gridToWorld(gridX, gridY) {
|
||||
return {
|
||||
x: (gridX * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2),
|
||||
z: (gridY * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2)
|
||||
};
|
||||
}
|
||||
|
||||
function isAdjacent(p1, p2) {
|
||||
const dx = Math.abs(p1.x - p2.x);
|
||||
const dy = Math.abs(p1.y - p2.y);
|
||||
// Adyacencia ortogonal (cruz)
|
||||
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
|
||||
}
|
||||
|
||||
// --- CREACIÓN DE MARCADORES (CANVAS TEXTURE) ---
|
||||
function createPathMarker(stepNumber) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Fondo Amarillo Semi-transparente
|
||||
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
|
||||
// Borde
|
||||
ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)';
|
||||
ctx.lineWidth = 10;
|
||||
ctx.strokeRect(0, 0, 128, 128);
|
||||
|
||||
// Número
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = 'bold 60px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(stepNumber.toString(), 64, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
// Importante para pixel art o gráficos nítidos, aunque aquí es texto
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
|
||||
// Crear el mesh
|
||||
const geometry = new THREE.PlaneGeometry(CONFIG.CELL_SIZE * 0.9, CONFIG.CELL_SIZE * 0.9);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide // Visible desde ambos lados
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.y = 0.05; // Ligeramente elevado sobre el suelo
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function updatePathVisuals() {
|
||||
// 1. Limpiar anteriores
|
||||
SESSION.pathMeshes.forEach(mesh => scene.remove(mesh));
|
||||
SESSION.pathMeshes = [];
|
||||
|
||||
// 2. Crear nuevos
|
||||
SESSION.path.forEach((pos, index) => {
|
||||
const marker = createPathMarker(index + 1);
|
||||
const worldPos = gridToWorld(pos.x, pos.y);
|
||||
marker.position.x = worldPos.x;
|
||||
marker.position.z = worldPos.z;
|
||||
scene.add(marker);
|
||||
SESSION.pathMeshes.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
// --- MANEJO VISUAL DE SELECCIÓN ---
|
||||
function updateSelectionVisuals() {
|
||||
GAME_STATE.entities.forEach(entity => {
|
||||
if (!entity.mesh) return;
|
||||
|
||||
if (entity.id === SESSION.selectedUnitId) {
|
||||
// SELECCIONADO: Amarillo + Opacidad 50%
|
||||
entity.mesh.material.color.setHex(0xffff00);
|
||||
entity.mesh.material.opacity = 0.5;
|
||||
entity.mesh.material.transparent = true;
|
||||
} else {
|
||||
// NO SELECCIONADO: Blanco (color original) + Opacidad 100%
|
||||
entity.mesh.material.color.setHex(0xffffff);
|
||||
entity.mesh.material.opacity = 1.0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- ANIMACIÓN DE MOVIMIENTO ---
|
||||
async function animateMovement() {
|
||||
if (SESSION.path.length === 0 || !SESSION.selectedUnitId) return;
|
||||
|
||||
SESSION.isAnimating = true;
|
||||
|
||||
const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId);
|
||||
if (!unit || !unit.mesh) {
|
||||
SESSION.isAnimating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Copiar el path para ir consumiéndolo
|
||||
const pathCopy = [...SESSION.path];
|
||||
|
||||
// Función helper para animar un solo paso
|
||||
const animateStep = (targetGridPos) => {
|
||||
return new Promise((resolve) => {
|
||||
const startPos = { x: unit.mesh.position.x, z: unit.mesh.position.z };
|
||||
const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y);
|
||||
const endPos = { x: targetWorldPos.x, z: targetWorldPos.z };
|
||||
|
||||
const duration = 300; // ms por paso
|
||||
const startTime = Date.now();
|
||||
const standeeHeight = ASSETS.standees[unit.type].height;
|
||||
|
||||
const hop = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing suave (ease-in-out)
|
||||
const eased = progress < 0.5
|
||||
? 2 * progress * progress
|
||||
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
||||
|
||||
// Interpolación lineal en X y Z
|
||||
unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased;
|
||||
unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased;
|
||||
|
||||
// Saltito parabólico en Y
|
||||
const hopHeight = 0.8; // Altura del salto
|
||||
const hopProgress = Math.sin(progress * Math.PI); // 0 -> 1 -> 0
|
||||
unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(hop);
|
||||
} else {
|
||||
// Asegurar posición final exacta
|
||||
unit.mesh.position.x = endPos.x;
|
||||
unit.mesh.position.z = endPos.z;
|
||||
unit.mesh.position.y = standeeHeight / 2;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
hop();
|
||||
});
|
||||
};
|
||||
|
||||
// Mover paso a paso
|
||||
for (let i = 0; i < pathCopy.length; i++) {
|
||||
const step = pathCopy[i];
|
||||
|
||||
// Animar el movimiento
|
||||
await animateStep(step);
|
||||
|
||||
// Actualizar posición lógica de la unidad
|
||||
unit.x = step.x;
|
||||
unit.y = step.y;
|
||||
|
||||
// Borrar el marcador de esta celda (el primero del array)
|
||||
SESSION.path.shift();
|
||||
updatePathVisuals();
|
||||
}
|
||||
|
||||
// Centrar la cámara en la posición final (manteniendo el ángulo/zoom)
|
||||
const endTarget = unit.mesh.position.clone();
|
||||
endTarget.y = 0; // Target siempre a nivel de suelo
|
||||
const currentCameraOffset = camera.position.clone().sub(controls.target);
|
||||
|
||||
controls.target.copy(endTarget);
|
||||
camera.position.copy(endTarget).add(currentCameraOffset);
|
||||
|
||||
// Al terminar, deseleccionar
|
||||
SESSION.selectedUnitId = null;
|
||||
updateSelectionVisuals();
|
||||
SESSION.isAnimating = false;
|
||||
}
|
||||
|
||||
// --- INTERACCIÓN ---
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
|
||||
window.addEventListener('pointerdown', (event) => {
|
||||
// Bloquear interacciones durante animación
|
||||
if (SESSION.isAnimating) return;
|
||||
|
||||
// CLICK IZQUIERDO: Selección y Pathfinding
|
||||
if (event.button === 0) {
|
||||
|
||||
// Calcular coordenadas normalizadas (-1 a +1)
|
||||
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||||
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
|
||||
// 1. Detectar Click en Entidades (Selección)
|
||||
// Buscamos intersecciones con los meshes de las entidades
|
||||
const entityMeshes = GAME_STATE.entities.map(e => e.mesh).filter(m => m);
|
||||
const intersectsEntities = raycaster.intersectObjects(entityMeshes);
|
||||
|
||||
if (intersectsEntities.length > 0) {
|
||||
// Hemos clickado una entidad
|
||||
const clickedMesh = intersectsEntities[0].object;
|
||||
const entity = GAME_STATE.entities.find(e => e.mesh === clickedMesh);
|
||||
if (entity) {
|
||||
console.log("Seleccionado:", entity.type);
|
||||
SESSION.selectedUnitId = entity.id;
|
||||
SESSION.path = []; // Resetear camino
|
||||
updatePathVisuals();
|
||||
updateSelectionVisuals(); // Actualizar color del standee
|
||||
return; // Cortamos aquí para no procesar click de suelo a la vez
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si hay unidad seleccionada, procesar Click en Suelo (Move)
|
||||
if (SESSION.selectedUnitId) {
|
||||
const intersectsGround = raycaster.intersectObject(raycastPlane);
|
||||
|
||||
if (intersectsGround.length > 0) {
|
||||
const point = intersectsGround[0].point;
|
||||
const gridPos = worldToGrid(point.x, point.z);
|
||||
|
||||
// LOGICA DEL PATHFINDING MANUAL
|
||||
|
||||
// Punto de Origen: La última casilla del path, O la casilla de la unidad si empieza
|
||||
let prevNode;
|
||||
if (SESSION.path.length > 0) {
|
||||
prevNode = SESSION.path[SESSION.path.length - 1];
|
||||
} else {
|
||||
const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId);
|
||||
prevNode = { x: unit.x, y: unit.y };
|
||||
}
|
||||
|
||||
// A. Caso Deshacer (Click en la última)
|
||||
if (SESSION.path.length > 0) {
|
||||
const lastNode = SESSION.path[SESSION.path.length - 1];
|
||||
if (lastNode.x === gridPos.x && lastNode.y === gridPos.y) {
|
||||
SESSION.path.pop(); // Borrar último
|
||||
updatePathVisuals();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// B. Caso Añadir (Tiene que ser adyacente al anterior)
|
||||
if (isAdjacent(prevNode, gridPos)) {
|
||||
// Comprobación opcional: Evitar bucles (no clickar en uno que ya está en el path)
|
||||
const alreadyInPath = SESSION.path.some(p => p.x === gridPos.x && p.y === gridPos.y);
|
||||
const isUnitPos = (gridPos.x === prevNode.x && gridPos.y === prevNode.y && SESSION.path.length === 0);
|
||||
|
||||
if (!alreadyInPath && !isUnitPos) {
|
||||
SESSION.path.push(gridPos);
|
||||
updatePathVisuals();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLICK DERECHO: Ejecutar movimiento
|
||||
if (event.button === 2) {
|
||||
event.preventDefault(); // Evitar menú contextual
|
||||
|
||||
if (SESSION.selectedUnitId && SESSION.path.length > 0) {
|
||||
animateMovement();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prevenir menú contextual del navegador
|
||||
window.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
// --- CARGA Y RENDERIZADO ---
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
|
||||
function loadTexture(path) {
|
||||
return new Promise((resolve) => {
|
||||
textureLoader.load(path, (tex) => {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
resolve(tex);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initWorld() {
|
||||
const tileTextures = {};
|
||||
const standeeTextures = {};
|
||||
|
||||
// Cargar Tiles
|
||||
for (const [key, def] of Object.entries(ASSETS.tiles)) {
|
||||
const tex = await loadTexture(def.src);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
// Repetición dinámica basada en tamaño (supone 2 unidades por repetición de textura base)
|
||||
tex.repeat.set(def.width / 2, def.height / 2);
|
||||
tileTextures[key] = tex;
|
||||
}
|
||||
// Cargar Standees
|
||||
for (const [key, def] of Object.entries(ASSETS.standees)) {
|
||||
standeeTextures[key] = await loadTexture(def.src);
|
||||
}
|
||||
|
||||
// Instanciar Tiles (Suelo)
|
||||
GAME_STATE.placedTiles.forEach(tileData => {
|
||||
const def = ASSETS.tiles[tileData.type];
|
||||
const tex = tileTextures[tileData.type];
|
||||
const worldWidth = def.width * CONFIG.CELL_SIZE;
|
||||
const worldHeight = def.height * CONFIG.CELL_SIZE;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(worldWidth, worldHeight);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
const originPos = gridToWorld(tileData.x, tileData.y);
|
||||
|
||||
// Ajuste de centro
|
||||
mesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
mesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
mesh.position.y = 0;
|
||||
|
||||
if (tileData.rotation) {
|
||||
mesh.rotation.z = tileData.rotation;
|
||||
}
|
||||
|
||||
scene.add(mesh);
|
||||
});
|
||||
|
||||
// Instanciar Entidades
|
||||
GAME_STATE.entities.forEach(entity => {
|
||||
const def = ASSETS.standees[entity.type];
|
||||
const tex = standeeTextures[entity.type];
|
||||
|
||||
const imgAspect = tex.image.width / tex.image.height;
|
||||
const height = def.height;
|
||||
const width = height * imgAspect;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(width, height);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
alphaTest: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
|
||||
const pos = gridToWorld(entity.x, entity.y);
|
||||
mesh.position.set(pos.x, height / 2, pos.z);
|
||||
|
||||
scene.add(mesh);
|
||||
entity.mesh = mesh;
|
||||
});
|
||||
|
||||
// --- PAREDES DE PRUEBA (ALREDEDOR DE TILE 1) ---
|
||||
// Tile 1 es 'tile_base' en 0,0. Tamaño 4x4 celdas -> 8x8 unidades world
|
||||
const tile1 = GAME_STATE.placedTiles.find(t => t.id === 1);
|
||||
if (tile1) {
|
||||
const wallTex = await loadTexture(ASSETS.tiles['wall_1'].src);
|
||||
wallTex.wrapS = THREE.RepeatWrapping;
|
||||
wallTex.wrapT = THREE.RepeatWrapping;
|
||||
wallTex.repeat.set(2, 2); // 2x2 repeticiones como solicitado
|
||||
|
||||
const baseTileWorldSize = 4 * CONFIG.CELL_SIZE; // 8 unidades
|
||||
const wallHeight = 2.5; // Altura de la pared
|
||||
const halfSize = baseTileWorldSize / 2;
|
||||
|
||||
// Calcular el centro exacto de la tile 1 tal como se hace al renderizarla
|
||||
// Copiamos la lógica de renderizado de tiles:
|
||||
const def = ASSETS.tiles[tile1.type];
|
||||
const worldWidth = def.width * CONFIG.CELL_SIZE;
|
||||
const worldHeight = def.height * CONFIG.CELL_SIZE;
|
||||
const originPos = gridToWorld(tile1.x, tile1.y);
|
||||
|
||||
const centerX = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
const centerZ = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
|
||||
const wallGeometry = new THREE.PlaneGeometry(baseTileWorldSize, wallHeight);
|
||||
const wallMaterial = new THREE.MeshStandardMaterial({
|
||||
map: wallTex,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const createWall = (offsetX, offsetZ, rotationY, opacity) => {
|
||||
const wall = new THREE.Mesh(wallGeometry, wallMaterial.clone());
|
||||
wall.material.opacity = opacity;
|
||||
wall.material.transparent = opacity < 1.0; // Solo transparente si opacity < 1
|
||||
// Posicionamos relativo al CENTRO de la tile
|
||||
wall.position.set(centerX + offsetX, wallHeight / 2, centerZ + offsetZ);
|
||||
wall.rotation.y = rotationY;
|
||||
wall.castShadow = true;
|
||||
wall.receiveShadow = true;
|
||||
scene.add(wall);
|
||||
SESSION.walls.push(wall);
|
||||
};
|
||||
|
||||
// Norte (Arriba en pantalla, Z menor) -> 100%
|
||||
createWall(0, -halfSize, 0, 1.0);
|
||||
// Sur (Abajo en pantalla, Z mayor) -> 50%
|
||||
createWall(0, halfSize, 0, 0.5);
|
||||
// Este (Derecha en pantalla, X mayor) -> 50%
|
||||
createWall(halfSize, 0, Math.PI / 2, 0.5);
|
||||
// Oeste (Izquierda en pantalla, X menor) -> 100%
|
||||
createWall(-halfSize, 0, Math.PI / 2, 1.0);
|
||||
|
||||
// --- PUERTA EN PARED NORTE ---
|
||||
const doorTex = await loadTexture(ASSETS.tiles['door_1'].src);
|
||||
const doorWidth = 1.5; // Ancho de la puerta
|
||||
const doorHeight = 2.0; // Alto de la puerta
|
||||
|
||||
const doorGeometry = new THREE.PlaneGeometry(doorWidth, doorHeight);
|
||||
const doorMaterial = new THREE.MeshStandardMaterial({
|
||||
map: doorTex,
|
||||
transparent: true,
|
||||
alphaTest: 0.1,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const door = new THREE.Mesh(doorGeometry, doorMaterial);
|
||||
// Posicionar en la celda (1, -1) - segunda celda de la pared norte
|
||||
const doorGridPos = gridToWorld(1, -1);
|
||||
door.position.set(doorGridPos.x, doorHeight / 2, centerZ - halfSize + 0.05);
|
||||
door.rotation.y = 0; // Misma rotación que pared norte
|
||||
scene.add(door);
|
||||
}
|
||||
}
|
||||
|
||||
initWorld();
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
camera.left = -d * aspect;
|
||||
camera.right = d * aspect;
|
||||
camera.top = d;
|
||||
camera.bottom = -d;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
@@ -12,7 +12,8 @@ body {
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden; /* Evitar scrollbars por el canvas */
|
||||
overflow: hidden;
|
||||
/* Evitar scrollbars por el canvas */
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -23,3 +24,64 @@ body {
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Compass UI */
|
||||
#compass {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
gap: 2px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.compass-btn {
|
||||
background: rgba(50, 50, 50, 0.8);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.compass-btn:hover {
|
||||
background: rgba(70, 70, 70, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.compass-btn.active {
|
||||
background: rgba(255, 200, 0, 0.9);
|
||||
border-color: rgba(255, 220, 0, 1);
|
||||
color: rgba(0, 0, 0, 1);
|
||||
box-shadow: 0 0 15px rgba(255, 200, 0, 0.6);
|
||||
}
|
||||
|
||||
#compass-n {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#compass-s {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
#compass-e {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
#compass-w {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
Reference in New Issue
Block a user