398 lines
14 KiB
JavaScript
398 lines
14 KiB
JavaScript
import * as THREE from 'three';
|
|
|
|
export class InteractionRenderer {
|
|
constructor(scene, renderer, camera, interactionPlane, getTextureCallback) {
|
|
this.scene = scene;
|
|
this.renderer = renderer;
|
|
this.camera = camera;
|
|
this.interactionPlane = interactionPlane;
|
|
this.getTexture = getTextureCallback;
|
|
|
|
this.raycaster = new THREE.Raycaster();
|
|
this.mouse = new THREE.Vector2();
|
|
|
|
this.highlightGroup = new THREE.Group();
|
|
this.scene.add(this.highlightGroup);
|
|
|
|
this.previewGroup = new THREE.Group();
|
|
this.scene.add(this.previewGroup);
|
|
|
|
this.projectionGroup = new THREE.Group();
|
|
this.scene.add(this.projectionGroup);
|
|
|
|
this.spellPreviewGroup = new THREE.Group();
|
|
this.scene.add(this.spellPreviewGroup);
|
|
|
|
this.rangedGroup = new THREE.Group();
|
|
this.scene.add(this.rangedGroup);
|
|
|
|
this.exitHighlightGroup = new THREE.Group();
|
|
this.scene.add(this.exitHighlightGroup);
|
|
|
|
this.pathGroup = new THREE.Group();
|
|
this.scene.add(this.pathGroup);
|
|
}
|
|
|
|
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null, getExitGroupCallback = null) {
|
|
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
|
|
};
|
|
};
|
|
|
|
const handleHover = (event) => {
|
|
if (!onHover) return;
|
|
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);
|
|
onHover(x, y);
|
|
}
|
|
};
|
|
|
|
this.renderer.domElement.addEventListener('mousemove', handleHover);
|
|
|
|
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 (getExitGroupCallback) {
|
|
const exitGroup = getExitGroupCallback();
|
|
if (exitGroup) {
|
|
const doorIntersects = this.raycaster.intersectObjects(exitGroup.children, false);
|
|
if (doorIntersects.length > 0) {
|
|
const doorMesh = doorIntersects[0].object;
|
|
// Only capture click if it is a door AND it is NOT open
|
|
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
|
// Clicked on a CLOSED 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);
|
|
});
|
|
}
|
|
|
|
showAreaPreview(cells, color = 0xffffff) {
|
|
this.spellPreviewGroup.clear();
|
|
if (!cells) return;
|
|
|
|
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
color: color,
|
|
transparent: true,
|
|
opacity: 0.5,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
cells.forEach(cell => {
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
mesh.rotation.x = -Math.PI / 2;
|
|
mesh.position.set(cell.x, 0.06, -cell.y);
|
|
this.spellPreviewGroup.add(mesh);
|
|
});
|
|
}
|
|
|
|
hideAreaPreview() {
|
|
this.spellPreviewGroup.clear();
|
|
}
|
|
|
|
// ========== PATH VISUALIZATION (PRESERVED) ==========
|
|
updatePathVisualization(path) {
|
|
this.pathGroup.clear();
|
|
|
|
if (!path || path.length === 0) return;
|
|
|
|
path.forEach((step, index) => {
|
|
const geometry = new THREE.PlaneGeometry(0.8, 0.8);
|
|
const texture = this.createNumberTexture(index + 1);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const plane = new THREE.Mesh(geometry, material);
|
|
plane.position.set(step.x, 0.02, -step.y);
|
|
plane.rotation.x = -Math.PI / 2;
|
|
|
|
plane.userData.stepIndex = index;
|
|
|
|
this.pathGroup.add(plane);
|
|
});
|
|
}
|
|
|
|
createNumberTexture(number) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 64;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Yellow background with 50% opacity
|
|
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
|
ctx.fillRect(0, 0, 64, 64);
|
|
|
|
// Border
|
|
ctx.strokeStyle = '#EDA900';
|
|
ctx.lineWidth = 4;
|
|
ctx.strokeRect(0, 0, 64, 64);
|
|
|
|
// Text
|
|
ctx.font = 'bold 36px Arial';
|
|
ctx.fillStyle = 'black';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(number.toString(), 32, 32);
|
|
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
return tex;
|
|
}
|
|
|
|
// Manual Placement
|
|
showPlacementPreview(preview) {
|
|
if (!preview) {
|
|
this.previewGroup.clear();
|
|
this.projectionGroup.clear();
|
|
return;
|
|
}
|
|
|
|
this.previewGroup.clear();
|
|
this.projectionGroup.clear();
|
|
|
|
const { card, cells, isValid, x, y, rotation } = preview;
|
|
|
|
// 1. FLOATING TILE (Y = 3)
|
|
if (card.textures && card.textures.length > 0) {
|
|
this.getTexture(card.textures[0], (texture) => {
|
|
const currentVariant = card.variants[rotation];
|
|
const rotWidth = currentVariant.width;
|
|
const rotHeight = currentVariant.height;
|
|
const cx = x + (rotWidth - 1) / 2;
|
|
const cy = y + (rotHeight - 1) / 2;
|
|
|
|
const baseWidth = card.variants.N.width;
|
|
const baseHeight = card.variants.N.height;
|
|
|
|
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const floatingTile = new THREE.Mesh(geometry, material);
|
|
floatingTile.rotation.x = -Math.PI / 2;
|
|
|
|
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
|
const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0;
|
|
floatingTile.rotation.z = -r * (Math.PI / 2);
|
|
|
|
floatingTile.position.set(cx, 3, -cy);
|
|
this.previewGroup.add(floatingTile);
|
|
});
|
|
}
|
|
|
|
// 2. GROUND PROJECTION
|
|
const baseColor = isValid ? 0x00ff00 : 0xff0000;
|
|
const exitKeys = new Set();
|
|
if (preview.variant && preview.variant.exits) {
|
|
preview.variant.exits.forEach(ex => {
|
|
const gx = x + ex.x;
|
|
const gy = y + ex.y;
|
|
exitKeys.add(`${gx},${gy} `);
|
|
});
|
|
}
|
|
|
|
cells.forEach(cell => {
|
|
const key = `${cell.x},${cell.y} `;
|
|
let color = baseColor;
|
|
if (exitKeys.has(key)) {
|
|
color = 0x0000ff;
|
|
}
|
|
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
color: color,
|
|
transparent: true,
|
|
opacity: 0.5,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const projection = new THREE.Mesh(geometry, material);
|
|
projection.rotation.x = -Math.PI / 2;
|
|
projection.position.set(cell.x, 0.02, -cell.y);
|
|
this.projectionGroup.add(projection);
|
|
});
|
|
}
|
|
|
|
hidePlacementPreview() {
|
|
this.previewGroup.clear();
|
|
this.projectionGroup.clear();
|
|
}
|
|
|
|
showRangedTargeting(hero, monster, losResult) {
|
|
this.rangedGroup.clear();
|
|
if (!hero || !monster || !losResult) return;
|
|
|
|
// 1. Orange Fluorescence Ring on Monster
|
|
const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32);
|
|
const ringMat = new THREE.MeshBasicMaterial({
|
|
color: 0xFFA500,
|
|
side: THREE.DoubleSide,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
ring.rotation.x = -Math.PI / 2;
|
|
ring.position.set(monster.x, 0.05, -monster.y);
|
|
this.rangedGroup.add(ring);
|
|
|
|
// 2. Dashed Line logic
|
|
const points = [];
|
|
points.push(new THREE.Vector3(hero.x, 0.8, -hero.y));
|
|
points.push(new THREE.Vector3(monster.x, 0.8, -monster.y));
|
|
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: losResult.clear ? 0x00FF00 : 0xFF0000,
|
|
dashSize: 0.2,
|
|
gapSize: 0.1,
|
|
});
|
|
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this.rangedGroup.add(line);
|
|
|
|
// 3. Blocker Visualization
|
|
if (!losResult.clear && losResult.blocker) {
|
|
const b = losResult.blocker;
|
|
if (b.type === 'hero' || b.type === 'monster') {
|
|
const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32);
|
|
const blockRingMat = new THREE.MeshBasicMaterial({
|
|
color: 0xFF0000,
|
|
side: THREE.DoubleSide,
|
|
transparent: true,
|
|
opacity: 1.0,
|
|
depthTest: false
|
|
});
|
|
const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat);
|
|
blockRing.rotation.x = -Math.PI / 2;
|
|
const bx = b.entity ? b.entity.x : b.x;
|
|
const by = b.entity ? b.entity.y : b.y;
|
|
blockRing.position.set(bx, 0.1, -by);
|
|
this.rangedGroup.add(blockRing);
|
|
}
|
|
}
|
|
}
|
|
|
|
enableDoorSelection(enabled, exitGroup) {
|
|
if (enabled) {
|
|
this.exitHighlightGroup.clear();
|
|
if (exitGroup) {
|
|
exitGroup.children.forEach(doorMesh => {
|
|
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
|
const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32);
|
|
const ringMat = new THREE.MeshBasicMaterial({
|
|
color: 0x00ff00,
|
|
side: THREE.DoubleSide,
|
|
transparent: true,
|
|
opacity: 0.6
|
|
});
|
|
const ring = new THREE.Mesh(ringGeom, ringMat);
|
|
ring.rotation.x = -Math.PI / 2;
|
|
ring.position.copy(doorMesh.position);
|
|
ring.position.y = 0.05;
|
|
|
|
// Store reference to door for click handling
|
|
doorMesh.userData.isExit = true;
|
|
const firstCell = doorMesh.userData.cells[0];
|
|
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
|
|
doorMesh.userData.exitData = {
|
|
x: firstCell.x,
|
|
y: firstCell.y,
|
|
direction: dirMap[doorMesh.userData.direction] || 'N'
|
|
};
|
|
|
|
this.exitHighlightGroup.add(ring);
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
this.exitHighlightGroup.clear();
|
|
}
|
|
}
|
|
}
|