Guardando estado actual
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copiamos primero los ficheros de dependencias para aprovechar la caché de Docker
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Instalamos dependencias
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copiamos el resto del código
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Exponemos el puerto de Vite
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Arrancamos en modo desarrollo
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1006 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 997 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1011 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 948 KiB |
|
After Width: | Height: | Size: 973 KiB |
BIN
assets/images/inspiracio/entrada.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
assets/images/inspiracio/esquelet.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
assets/images/inspiracio/monstre1.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
assets/images/standees/barbaro.png
Normal file
|
After Width: | Height: | Size: 571 KiB |
BIN
assets/images/standees/esqueleto.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
assets/images/standees/standee1.png
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
assets/images/standees/standee2.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
assets/images/tiles/tile4x4.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/images/tiles/tile4x4_blue.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/images/tiles/tile4x4_orange.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Masmorres Isometric View</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
manifest.md
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "masmorres-isometric",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.160.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
475
src/main.js
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
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 },
|
||||||
|
},
|
||||||
|
standees: {
|
||||||
|
'hero_1': { src: '/assets/images/standees/barbaro.png', height: 3 },
|
||||||
|
'hero_2': { src: '/assets/images/standees/esqueleto.png', height: 3 },
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
25
src/style.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color-scheme: light dark;
|
||||||
|
background-color: #242424;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden; /* Evitar scrollbars por el canvas */
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
14
vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
// Permite que el servidor escuche en todas las interfaces de red (necesario para Docker)
|
||||||
|
host: true,
|
||||||
|
// Lista blanca de dominios permitidos
|
||||||
|
allowedHosts: [
|
||||||
|
'masmorres.martivich.es',
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||