Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 42 additions & 17 deletions src/editor/rendering3d/EditorObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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;
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/editor/rendering3d/EditorTerrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -117,15 +117,15 @@ 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);
this.mesh.add(this.guardrailMesh.mesh);
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;
}
Expand Down
204 changes: 204 additions & 0 deletions src/editor/rendering3d/EditorWater.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string>
): 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;