diff --git a/src/editor/rendering3d/EditorObjects.ts b/src/editor/rendering3d/EditorObjects.ts index 0a2708a7..44954e55 100644 --- a/src/editor/rendering3d/EditorObjects.ts +++ b/src/editor/rendering3d/EditorObjects.ts @@ -278,12 +278,12 @@ export class EditorObjects { const modelInstance = this.modelsInitialized ? EditorModelLoader.getModelInstance(obj.type) : null; if (modelInstance) { - // Use real model + // Use real model - already normalized and grounded (bottom at y=0) mesh = new THREE.Group(); mesh.add(modelInstance); isRealModel = true; - // Get model height from bounding box + // Get model height from bounding box (after normalization, min.y=0 and max.y=height) const box = new THREE.Box3().setFromObject(modelInstance); visualHeight = box.max.y - box.min.y; } else { @@ -307,11 +307,14 @@ export class EditorObjects { } // Position the mesh - mesh.position.set(obj.x, terrainHeight + (visualHeight * scale) / 2, obj.y); + // Real models are grounded (bottom at y=0), so position at terrainHeight directly + // Placeholder geometries are centered, so need to add height/2 + const yOffset = isRealModel ? 0 : (visualHeight * scale) / 2; + mesh.position.set(obj.x, terrainHeight + yOffset, obj.y); - // Create hit mesh for easier selection + // Create hit mesh for easier selection (always centered for raycast) const hitSize = Math.max(2, radius * 0.5); - const hitGeometry = new THREE.CylinderGeometry(hitSize, hitSize, visualHeight * 1.5, 8); + const hitGeometry = new THREE.CylinderGeometry(hitSize, hitSize, visualHeight * scale * 1.5, 8); const hitMaterial = new THREE.MeshBasicMaterial({ visible: false }); const hitMesh = new THREE.Mesh(hitGeometry, hitMaterial); hitMesh.position.set(obj.x, terrainHeight + (visualHeight * scale) / 2, obj.y); @@ -328,7 +331,7 @@ export class EditorObjects { selectionRing.rotation.x = -Math.PI / 2; selectionRing.position.set(obj.x, terrainHeight + 0.1, obj.y); - // Create label sprite + // Create label sprite (above the model) const label = this.createLabel(objType?.name || obj.type, objType?.icon || '●'); label.position.set(obj.x, terrainHeight + visualHeight * scale + 2.5, obj.y); @@ -367,13 +370,24 @@ export class EditorObjects { const terrainHeight = this.getTerrainHeight ? this.getTerrainHeight(x, y) : 0; const scale = instance.mesh.children[0]?.scale.x || 1; - const visual = OBJECT_VISUALS[instance.type] || { height: 2 }; - const height = instance.isRealModel ? 3 : visual.height; - instance.mesh.position.set(x, terrainHeight + (height * scale) / 2, y); - instance.hitMesh.position.set(x, terrainHeight + (height * scale) / 2, y); + // Get visual height - for real models compute from bounding box, for placeholders use config + let visualHeight = 2; + if (instance.isRealModel && instance.mesh.children[0]) { + const box = new THREE.Box3().setFromObject(instance.mesh.children[0]); + visualHeight = (box.max.y - box.min.y) / scale; // Divide by scale to get base height + } else { + const visual = OBJECT_VISUALS[instance.type] || { height: 2 }; + visualHeight = visual.height; + } + + // Real models are grounded, placeholders are centered + const yOffset = instance.isRealModel ? 0 : (visualHeight * scale) / 2; + + instance.mesh.position.set(x, terrainHeight + yOffset, y); + instance.hitMesh.position.set(x, terrainHeight + (visualHeight * scale) / 2, y); instance.selectionRing.position.set(x, terrainHeight + 0.1, y); - instance.label.position.set(x, terrainHeight + height * scale + 2.5, y); + instance.label.position.set(x, terrainHeight + visualHeight * scale + 2.5, y); } /** @@ -383,21 +397,32 @@ export class EditorObjects { const instance = this.objects.get(id); if (!instance) return; - const visual = OBJECT_VISUALS[instance.type] || { height: 2 }; - const height = instance.isRealModel ? 3 : visual.height; const x = instance.mesh.position.x; const z = instance.mesh.position.z; const terrainHeight = this.getTerrainHeight ? this.getTerrainHeight(x, z) : 0; - // Update inner mesh scale + // Update inner mesh scale first if (instance.mesh.children[0]) { instance.mesh.children[0].scale.setScalar(scale); } + // Get visual height - for real models compute from bounding box, for placeholders use config + let visualHeight = 2; + if (instance.isRealModel && instance.mesh.children[0]) { + const box = new THREE.Box3().setFromObject(instance.mesh.children[0]); + visualHeight = (box.max.y - box.min.y) / scale; // Divide by scale to get base height + } else { + const visual = OBJECT_VISUALS[instance.type] || { height: 2 }; + visualHeight = visual.height; + } + + // Real models are grounded, placeholders are centered + const yOffset = instance.isRealModel ? 0 : (visualHeight * scale) / 2; + // Update positions - instance.mesh.position.y = terrainHeight + (height * scale) / 2; - instance.hitMesh.position.y = terrainHeight + (height * scale) / 2; - instance.label.position.y = terrainHeight + height * scale + 2.5; + instance.mesh.position.y = terrainHeight + yOffset; + instance.hitMesh.position.y = terrainHeight + (visualHeight * scale) / 2; + instance.label.position.y = terrainHeight + visualHeight * scale + 2.5; } /** diff --git a/src/editor/rendering3d/EditorTerrain.ts b/src/editor/rendering3d/EditorTerrain.ts index c7f12a22..fc48c1f2 100644 --- a/src/editor/rendering3d/EditorTerrain.ts +++ b/src/editor/rendering3d/EditorTerrain.ts @@ -12,7 +12,7 @@ import * as THREE from 'three'; import type { EditorMapData, EditorCell } from '../config/EditorConfig'; import { CliffMesh } from './CliffMesh'; import { GuardrailMesh } from './GuardrailMesh'; -import { WaterMesh } from '@/rendering/WaterMesh'; +import { EditorWater } from './EditorWater'; import { clamp } from '@/utils/math'; // Height scale factor (matches game terrain) @@ -70,7 +70,7 @@ export class EditorTerrain { // Platform terrain rendering private cliffMesh: CliffMesh; private guardrailMesh: GuardrailMesh; - private waterMesh: WaterMesh; + private waterMesh: EditorWater; private showGuardrails: boolean = true; private cellSize: number; @@ -117,7 +117,7 @@ export class EditorTerrain { // Create platform terrain meshes (cliff faces and guardrails) this.cliffMesh = new CliffMesh({ cellSize: this.cellSize }); this.guardrailMesh = new GuardrailMesh({ cellSize: this.cellSize }); - this.waterMesh = new WaterMesh(); + this.waterMesh = new EditorWater(); // Add as children (inherits rotation) this.mesh.add(this.cliffMesh.mesh); @@ -125,7 +125,7 @@ export class EditorTerrain { this.mesh.add(this.waterMesh.group); // Counter-rotate water mesh to cancel parent terrain rotation. - // WaterMesh creates geometry in world space (Y = height), but as a child of + // EditorWater creates geometry in world space (Y = height), but as a child of // the terrain mesh (rotated -90 around X), it needs compensation. this.waterMesh.group.rotation.x = Math.PI / 2; } diff --git a/src/editor/rendering3d/EditorWater.ts b/src/editor/rendering3d/EditorWater.ts new file mode 100644 index 00000000..064ba840 --- /dev/null +++ b/src/editor/rendering3d/EditorWater.ts @@ -0,0 +1,204 @@ +/** + * EditorWater - Simple water rendering for the map editor + * + * Creates basic animated water planes for water features. + * Uses simple materials instead of complex WaterMesh addon to ensure + * reliable rendering in the editor environment. + */ + +import * as THREE from 'three'; +import type { EditorCell } from '../config/EditorConfig'; + +// Height scale factor (matches game terrain) +const HEIGHT_SCALE = 0.04; + +// Water surface offset above terrain +const WATER_SURFACE_OFFSET = 0.2; + +interface WaterRegion { + minX: number; + maxX: number; + minY: number; + maxY: number; + avgElevation: number; + isDeep: boolean; +} + +export class EditorWater { + public group: THREE.Group; + + private waterMeshes: THREE.Mesh[] = []; + private time: number = 0; + + constructor() { + this.group = new THREE.Group(); + } + + /** + * Build water meshes from editor terrain data + */ + public buildFromEditorData( + terrain: EditorCell[][], + width: number, + height: number + ): void { + this.clear(); + + const visited = new Set(); + const regions: WaterRegion[] = []; + + // Find water regions via flood fill + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const key = `${x},${y}`; + if (visited.has(key)) continue; + + const cell = terrain[y]?.[x]; + if (!cell) continue; + + const feature = cell.feature || 'none'; + if (feature !== 'water_shallow' && feature !== 'water_deep') continue; + + const region = this.floodFillRegion(terrain, x, y, width, height, visited); + if (region) { + regions.push(region); + } + } + } + + // Create mesh for each region + for (const region of regions) { + this.createRegionMesh(region); + } + } + + private floodFillRegion( + terrain: EditorCell[][], + startX: number, + startY: number, + width: number, + height: number, + visited: Set + ): WaterRegion | null { + const startCell = terrain[startY]?.[startX]; + if (!startCell) return null; + + const startFeature = startCell.feature || 'none'; + const isDeep = startFeature === 'water_deep'; + const targetFeature = startFeature; + + const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }]; + let minX = startX, + maxX = startX, + minY = startY, + maxY = startY; + let totalElevation = 0; + let count = 0; + + while (queue.length > 0) { + const { x, y } = queue.shift()!; + const key = `${x},${y}`; + + if (visited.has(key)) continue; + if (x < 0 || x >= width || y < 0 || y >= height) continue; + + const cell = terrain[y]?.[x]; + if (!cell) continue; + + const feature = cell.feature || 'none'; + if (feature !== targetFeature) continue; + + visited.add(key); + + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + totalElevation += cell.elevation; + count++; + + queue.push({ x: x - 1, y }); + queue.push({ x: x + 1, y }); + queue.push({ x, y: y - 1 }); + queue.push({ x, y: y + 1 }); + } + + if (count === 0) return null; + + return { + minX, + maxX, + minY, + maxY, + avgElevation: totalElevation / count, + isDeep, + }; + } + + private createRegionMesh(region: WaterRegion): void { + const regionWidth = region.maxX - region.minX + 1; + const regionHeight = region.maxY - region.minY + 1; + + // Create plane geometry + const geometry = new THREE.PlaneGeometry(regionWidth, regionHeight, 1, 1); + + // Water color based on depth + const color = region.isDeep ? 0x1565c0 : 0x42a5f5; + const opacity = region.isDeep ? 0.85 : 0.6; + + const material = new THREE.MeshLambertMaterial({ + color, + transparent: true, + opacity, + side: THREE.DoubleSide, + depthWrite: false, + }); + + const mesh = new THREE.Mesh(geometry, material); + + // Position in world space (Y = up in editor) + const centerX = region.minX + regionWidth / 2; + const centerZ = region.minY + regionHeight / 2; + const waterHeight = region.avgElevation * HEIGHT_SCALE + WATER_SURFACE_OFFSET; + + mesh.rotation.x = -Math.PI / 2; + mesh.position.set(centerX, waterHeight, centerZ); + mesh.renderOrder = 1; // Render after terrain + + this.waterMeshes.push(mesh); + this.group.add(mesh); + } + + /** + * Update water animation + */ + public update(deltaTime: number): void { + this.time += deltaTime; + + // Simple wave animation via slight position oscillation + for (const mesh of this.waterMeshes) { + mesh.position.y += Math.sin(this.time * 2) * 0.001; + } + } + + /** + * Clear all water meshes + */ + public clear(): void { + for (const mesh of this.waterMeshes) { + mesh.geometry.dispose(); + (mesh.material as THREE.Material).dispose(); + this.group.remove(mesh); + } + this.waterMeshes = []; + } + + /** + * Dispose all resources + */ + public dispose(): void { + this.clear(); + } +} + +export default EditorWater;