feat: Implement door interaction system and UI improvements
- Add interactive door system with click detection on door meshes - Create custom DoorModal component replacing browser confirm() - Implement door opening with texture change to door1_open.png - Add additive door rendering to preserve opened doors - Remove exploration button and requestExploration method - Implement camera orbit controls with smooth animations - Add active view indicator (yellow highlight) on camera buttons - Add vertical zoom slider with label - Fix camera to maintain isometric perspective while rotating - Integrate all systems into main game loop
This commit is contained in:
@@ -30,6 +30,21 @@ export class GameRenderer {
|
||||
// 5. Textures
|
||||
this.textureLoader = new THREE.TextureLoader();
|
||||
this.textureCache = new Map();
|
||||
// 6. Interaction
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.mouse = new THREE.Vector2();
|
||||
this.interactionPlane = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1000, 1000),
|
||||
new THREE.MeshBasicMaterial({ visible: false })
|
||||
);
|
||||
this.interactionPlane.rotation.x = -Math.PI / 2;
|
||||
this.scene.add(this.interactionPlane);
|
||||
|
||||
this.selectionMesh = null;
|
||||
this.highlightGroup = new THREE.Group();
|
||||
this.scene.add(this.highlightGroup);
|
||||
|
||||
this.entities = new Map();
|
||||
}
|
||||
|
||||
setupLights() {
|
||||
@@ -44,6 +59,372 @@ export class GameRenderer {
|
||||
this.scene.add(dirLight);
|
||||
}
|
||||
|
||||
setupInteraction(cameraGetter, onClick, onRightClick) {
|
||||
const getMousePos = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
y: -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
};
|
||||
};
|
||||
|
||||
this.renderer.domElement.addEventListener('click', (event) => {
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
|
||||
// First, check if we clicked on a door mesh
|
||||
if (this.exitGroup) {
|
||||
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
|
||||
if (doorIntersects.length > 0) {
|
||||
const doorMesh = doorIntersects[0].object;
|
||||
if (doorMesh.userData.isDoor) {
|
||||
// Clicked on a door! Call onClick with a special door object
|
||||
onClick(null, null, doorMesh);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no door clicked, proceed with normal cell click
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onClick(x, y, null);
|
||||
}
|
||||
});
|
||||
|
||||
this.renderer.domElement.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onRightClick(x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
highlightCells(cells) {
|
||||
this.highlightGroup.clear();
|
||||
if (!cells || cells.length === 0) return;
|
||||
|
||||
cells.forEach((cell, index) => {
|
||||
// 1. Create Canvas with Number
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "rgba(255, 255, 0, 0.5)";
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = "rgba(255, 255, 0, 1)";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeRect(0, 0, 128, 128);
|
||||
|
||||
// Text (Step Number)
|
||||
ctx.font = "bold 60px Arial";
|
||||
ctx.fillStyle = "black";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText((index + 1).toString(), 64, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(cell.x, 0.05, -cell.y);
|
||||
|
||||
this.highlightGroup.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
addEntity(entity) {
|
||||
if (this.entities.has(entity.id)) return;
|
||||
|
||||
console.log(`[GameRenderer] Adding entity ${entity.name}`);
|
||||
// Standee: Larger Size (+30%)
|
||||
// Old: 0.8 x 1.2 -> New: 1.04 x 1.56
|
||||
const w = 1.04;
|
||||
const h = 1.56;
|
||||
const geometry = new THREE.PlaneGeometry(w, h);
|
||||
|
||||
this.getTexture(entity.texturePath, (texture) => {
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
alphaTest: 0.1
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
// Store target position for animation logic
|
||||
mesh.userData = {
|
||||
pathQueue: [],
|
||||
isMoving: false,
|
||||
startPos: null,
|
||||
targetPos: null,
|
||||
startTime: 0
|
||||
};
|
||||
|
||||
mesh.position.set(entity.x, h / 2, -entity.y);
|
||||
|
||||
// Selection Circle
|
||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 });
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -h / 2 + 0.05;
|
||||
ring.visible = false;
|
||||
ring.name = "SelectionRing";
|
||||
mesh.add(ring);
|
||||
|
||||
this.scene.add(mesh);
|
||||
this.entities.set(entity.id, mesh);
|
||||
});
|
||||
}
|
||||
|
||||
toggleEntitySelection(entityId, isSelected) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (mesh) {
|
||||
const ring = mesh.getObjectByName("SelectionRing");
|
||||
if (ring) ring.visible = isSelected;
|
||||
}
|
||||
}
|
||||
|
||||
moveEntityAlongPath(entity, path) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
mesh.userData.pathQueue = [...path];
|
||||
this.highlightGroup.clear();
|
||||
}
|
||||
}
|
||||
|
||||
updateEntityPosition(entity) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
// Prevent snapping if animation is active
|
||||
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
|
||||
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
|
||||
}
|
||||
}
|
||||
|
||||
updateAnimations(time) {
|
||||
this.entities.forEach((mesh, id) => {
|
||||
const data = mesh.userData;
|
||||
|
||||
if (!data.isMoving && data.pathQueue.length > 0) {
|
||||
const nextStep = data.pathQueue.shift();
|
||||
|
||||
data.isMoving = true;
|
||||
data.startTime = time;
|
||||
data.startPos = mesh.position.clone();
|
||||
// Target: x, y (grid) -> x, z (world)
|
||||
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
|
||||
}
|
||||
|
||||
if (data.isMoving) {
|
||||
const duration = 400; // ms per tile
|
||||
const elapsed = time - data.startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Lerp X/Z
|
||||
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, t);
|
||||
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, t);
|
||||
|
||||
// Jump Arc
|
||||
const baseHeight = 1.56 / 2;
|
||||
mesh.position.y = baseHeight + (0.5 * Math.sin(t * Math.PI));
|
||||
|
||||
if (t >= 1) {
|
||||
mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z);
|
||||
data.isMoving = false;
|
||||
|
||||
// IF Finished Sequence (Queue empty)
|
||||
if (data.pathQueue.length === 0) {
|
||||
// Check if it's the player (id 'p1')
|
||||
if (id === 'p1' && this.onHeroFinishedMove) {
|
||||
// Grid Coords from World Coords (X, -Z)
|
||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
renderExits(exits) {
|
||||
// Cancel any pending render
|
||||
if (this._pendingExitRender) {
|
||||
this._pendingExitRender = false;
|
||||
}
|
||||
|
||||
// Create exitGroup if it doesn't exist
|
||||
if (!this.exitGroup) {
|
||||
this.exitGroup = new THREE.Group();
|
||||
this.scene.add(this.exitGroup);
|
||||
}
|
||||
|
||||
if (!exits || exits.length === 0) return;
|
||||
|
||||
// Get existing door cells to avoid duplicates
|
||||
const existingDoorCells = new Set();
|
||||
this.exitGroup.children.forEach(child => {
|
||||
if (child.userData.isDoor) {
|
||||
child.userData.cells.forEach(cell => {
|
||||
existingDoorCells.add(`${cell.x},${cell.y}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out exits that already have doors
|
||||
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y}`));
|
||||
|
||||
if (newExits.length === 0) {
|
||||
console.log('[renderExits] No new doors to render');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[renderExits] Rendering ${newExits.length} new door cells`);
|
||||
|
||||
// Set flag for this render
|
||||
this._pendingExitRender = true;
|
||||
const thisRender = this._pendingExitRender;
|
||||
|
||||
// LOAD TEXTURE
|
||||
this.getTexture('/assets/images/dungeon1/doors/door1_closed.png', (texture) => {
|
||||
// Check if this render was cancelled
|
||||
if (!thisRender || this._pendingExitRender !== thisRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
// Grouping Logic
|
||||
const processed = new Set();
|
||||
const doors = [];
|
||||
|
||||
// Helper to normalize direction to number
|
||||
const normalizeDir = (dir) => {
|
||||
if (typeof dir === 'number') return dir;
|
||||
const map = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
||||
return map[dir] ?? dir;
|
||||
};
|
||||
|
||||
newExits.forEach((ex, i) => {
|
||||
const key = `${ex.x},${ex.y}`;
|
||||
const exDir = normalizeDir(ex.direction);
|
||||
|
||||
if (processed.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let partner = null;
|
||||
for (let j = i + 1; j < newExits.length; j++) {
|
||||
const other = newExits[j];
|
||||
const otherKey = `${other.x},${other.y}`;
|
||||
const otherDir = normalizeDir(other.direction);
|
||||
|
||||
if (processed.has(otherKey)) continue;
|
||||
|
||||
if (exDir !== otherDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let isAdj = false;
|
||||
if (exDir === 0 || exDir === 2) {
|
||||
// North/South: check if same Y and adjacent X
|
||||
isAdj = (ex.y === other.y && Math.abs(ex.x - other.x) === 1);
|
||||
} else {
|
||||
// East/West: check if same X and adjacent Y
|
||||
isAdj = (ex.x === other.x && Math.abs(ex.y - other.y) === 1);
|
||||
}
|
||||
|
||||
if (isAdj) {
|
||||
partner = other;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (partner) {
|
||||
doors.push([ex, partner]);
|
||||
processed.add(key);
|
||||
processed.add(`${partner.x},${partner.y}`);
|
||||
} else {
|
||||
doors.push([ex]);
|
||||
processed.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Render Doors
|
||||
doors.forEach((door, idx) => {
|
||||
const d1 = door[0];
|
||||
const d2 = door.length > 1 ? door[1] : d1;
|
||||
|
||||
const centerX = (d1.x + d2.x) / 2;
|
||||
const centerY = (d1.y + d2.y) / 2;
|
||||
const dir = normalizeDir(d1.direction);
|
||||
|
||||
let angle = 0;
|
||||
let worldX = centerX;
|
||||
let worldZ = -centerY;
|
||||
|
||||
if (dir === 0) {
|
||||
angle = 0;
|
||||
worldZ -= 0.5;
|
||||
} else if (dir === 2) {
|
||||
angle = 0;
|
||||
worldZ += 0.5;
|
||||
} else if (dir === 1) {
|
||||
angle = Math.PI / 2;
|
||||
worldX += 0.5;
|
||||
} else if (dir === 3) {
|
||||
angle = Math.PI / 2;
|
||||
worldX -= 0.5;
|
||||
}
|
||||
|
||||
const geom = new THREE.PlaneGeometry(2, 2);
|
||||
// Clone material for each door so they can have independent textures
|
||||
const doorMat = mat.clone();
|
||||
const mesh = new THREE.Mesh(geom, doorMat);
|
||||
|
||||
mesh.position.set(worldX, 1, worldZ);
|
||||
mesh.rotation.y = angle;
|
||||
|
||||
// Store door data for interaction (new doors always start closed)
|
||||
mesh.userData = {
|
||||
isDoor: true,
|
||||
isOpen: false,
|
||||
cells: [d1, d2],
|
||||
direction: dir
|
||||
};
|
||||
mesh.name = `door_${idx}`;
|
||||
|
||||
this.exitGroup.add(mesh);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
if (this.camera) {
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
@@ -115,11 +496,7 @@ export class GameRenderer {
|
||||
});
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
|
||||
// DEBUG: Add a wireframe border to see the physical title limits
|
||||
const borderGeom = new THREE.EdgesGeometry(geometry);
|
||||
const borderMat = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 2 });
|
||||
const border = new THREE.LineSegments(borderGeom, borderMat);
|
||||
plane.add(border);
|
||||
|
||||
|
||||
// Initial Rotation: Plane X-Y to X-Z
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
@@ -157,4 +534,51 @@ export class GameRenderer {
|
||||
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}, tex: ${tileDef?.textures?.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
openDoor(doorMesh) {
|
||||
if (!doorMesh || !doorMesh.userData.isDoor) return;
|
||||
if (doorMesh.userData.isOpen) return; // Already open
|
||||
|
||||
// Load open door texture
|
||||
this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => {
|
||||
doorMesh.material.map = texture;
|
||||
doorMesh.material.needsUpdate = true;
|
||||
doorMesh.userData.isOpen = true;
|
||||
console.log('[GameRenderer] Door opened');
|
||||
});
|
||||
}
|
||||
|
||||
getDoorAtPosition(x, y) {
|
||||
if (!this.exitGroup) return null;
|
||||
|
||||
// Check all doors in exitGroup
|
||||
for (const child of this.exitGroup.children) {
|
||||
if (child.userData.isDoor) {
|
||||
// Check if any of the door's cells match the position
|
||||
for (const cell of child.userData.cells) {
|
||||
if (cell.x === x && cell.y === y) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
|
||||
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
||||
|
||||
// Check if player is adjacent to any of the door's cells
|
||||
for (const cell of doorMesh.userData.cells) {
|
||||
const dx = Math.abs(playerX - cell.x);
|
||||
const dy = Math.abs(playerY - cell.y);
|
||||
|
||||
// Adjacent means distance of 1 in one direction and 0 in the other
|
||||
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user