diff --git a/src/core/scene/common/gizmo.ts b/src/core/scene/common/gizmo.ts index e5b94a445..cfe8b2335 100644 --- a/src/core/scene/common/gizmo.ts +++ b/src/core/scene/common/gizmo.ts @@ -29,6 +29,7 @@ export interface IGizmoService { removeAllGizmoOfNode(node: any, recursive?: boolean): void; clearAllGizmos(): void; callAllGizmoFuncOfNode(node: any, funcName: string, ...params: any[]): boolean; + getComponentGizmo(component: any): any; onUpdate(deltaTime: number): void; // 与 cocos-editor GizmoManager 一致:GizmoConfig 方法 diff --git a/src/core/scene/common/index.ts b/src/core/scene/common/index.ts index c37aabece..ec5cb318d 100644 --- a/src/core/scene/common/index.ts +++ b/src/core/scene/common/index.ts @@ -16,4 +16,5 @@ export * from './gizmo'; export * from './scene-view'; export * from './preview'; export * from './ui'; +export * from './terrain'; export * from './message'; diff --git a/src/core/scene/common/terrain.ts b/src/core/scene/common/terrain.ts new file mode 100644 index 000000000..2415af999 --- /dev/null +++ b/src/core/scene/common/terrain.ts @@ -0,0 +1,28 @@ +import type { Terrain } from 'cc'; + +/** Terrain 编辑器的非 UI 接口;pink 只需要调用这些接口。 */ +export interface ITerrainService { + readonly name: 'cc.Terrain'; + readonly editedComponents: Terrain[]; + readonly selectedComponents: Terrain[]; + isTerrainChange: boolean; + select(nodeUuid: string): void; + unselect(nodeUuid: string): void; + close(): Promise<0 | 1 | 2>; + saveAsset(isClose?: boolean, component?: Terrain): Promise<0 | 1 | 2>; + saveAssetDialog(file?: string, isClose?: boolean): Promise<0 | 1 | 2>; + addAssetToComp(assetUuid: string): Promise; + serialize(component: Terrain): Uint8Array; + onSculpt(node: any): void; +} + +export type IPublicTerrainService = Pick; + +export interface ITerrainEvents { + 'terrain:changed': [component: Terrain]; + 'terrain:sculpt': [node: any]; + 'terrain:block-update': []; +} diff --git a/src/core/scene/scene-process/service/component/index.ts b/src/core/scene/scene-process/service/component/index.ts index d373fd7ec..8e1b84cc2 100644 --- a/src/core/scene/scene-process/service/component/index.ts +++ b/src/core/scene/scene-process/service/component/index.ts @@ -8,6 +8,7 @@ import { Component, MissingScript } from 'cc'; import { IProperty } from '../../../@types/public'; import { type IComponentEvents } from '../../../common'; import { ServiceEvents } from '../core/global-events'; +import { queryRegisteredService } from '../core/decorator'; export class CompManager { protected _recycleComponent: Record = {}; @@ -213,6 +214,14 @@ export class CompManager { const pathKeys = (name || '').split('.'); const methodName = pathKeys.pop() || ''; + // 3.x terrain UI calls component methods through `gizmo.xxx`. Gizmos are + // held by GizmoService's WeakMap in CLI, so they cannot be resolved by lodash/get. + if (pathKeys.length === 1 && pathKeys[0] === 'gizmo') { + const gizmo = queryRegisteredService('Gizmo')?.getComponentGizmo?.(comp); + if (gizmo && methodName && typeof gizmo[methodName] === 'function') { + return await gizmo[methodName](...(args || [])); + } + } if (pathKeys.length > 0) { const methodObjPath = pathKeys.join('.'); const methodObj = get(comp, methodObjPath); diff --git a/src/core/scene/scene-process/service/editor.ts b/src/core/scene/scene-process/service/editor.ts index 502d3071d..64b95100a 100644 --- a/src/core/scene/scene-process/service/editor.ts +++ b/src/core/scene/scene-process/service/editor.ts @@ -237,6 +237,9 @@ export class EditorService extends BaseService implements IEditor } this.invalidateEditorSession(); + if (params.save !== false) { + await this.saveTerrainAssets(); + } const result = await editor.close({ save: params.save ?? true }); if (editor === this.editorMap.get(currentEditorUuid)) { @@ -273,6 +276,7 @@ export class EditorService extends BaseService implements IEditor const urlOrUUID = params.urlOrUUID ?? this.currentEditorUuid; try { const { assetInfo, currentEditorUuid, editor } = await this.resolveSaveTarget(urlOrUUID); + await this.saveTerrainAssets(); const result = assetInfo.uuid === currentEditorUuid ? await editor.save() : await this.recoverDeletedSourceTo(assetInfo, currentEditorUuid, editor); @@ -289,6 +293,22 @@ export class EditorService extends BaseService implements IEditor } } + /** Terrain data lives in .terrain assets, not in the scene JSON. */ + private async saveTerrainAssets(): Promise { + try { + const terrain = (Service as any).Terrain; + if (!terrain?.saveAsset) return; + const result = await terrain.saveAsset(false); + if (result === 2) { + throw new Error('Terrain asset save failed or requires a Save As target.'); + } + } catch (error) { + // During early bootstrap or isolated editor tests TerrainService may + // not be registered. Real terrain save failures use the explicit error above. + if (error instanceof Error && error.message.includes('requires a Save As')) throw error; + } + } + private async recoverDeletedSourceTo(assetInfo: IAssetInfo, currentEditorUuid: string, editor: SceneEditor | PrefabEditor): Promise { const currentAssetInfo = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [currentEditorUuid]); if (currentAssetInfo) { diff --git a/src/core/scene/scene-process/service/gizmo.ts b/src/core/scene/scene-process/service/gizmo.ts index 48d6a3721..29224d9c7 100644 --- a/src/core/scene/scene-process/service/gizmo.ts +++ b/src/core/scene/scene-process/service/gizmo.ts @@ -42,6 +42,17 @@ import './gizmo/components/web-view'; import './gizmo/components/light-probe-group'; import './gizmo/components/reflection-probe'; +// Terrain is available only in engine versions that expose the Terrain +// component. Keep its registration conditional so lightweight CLI/test `cc` +// mocks do not eagerly load the terrain controller's rendering dependencies. +try { + if ((require('cc') as { Terrain?: unknown }).Terrain) { + require('./gizmo/components/terrain'); + } +} catch { + // Terrain registration is optional when the engine module is incomplete. +} + type TGizmoType = 'icon' | 'persistent' | 'component'; // 与 cocos-editor GizmoConfig 一致:Gizmo 全局显示配置 @@ -821,6 +832,11 @@ export class GizmoService extends BaseService implements IGizmoSer return !stopped; } + /** Returns the component gizmo without exposing the internal WeakMap to callers. */ + getComponentGizmo(component: Component): GizmoBase | null { + return getGizmoProperty('component', component) ?? null; + } + // ── Selection integration (与 cocos-editor SelectionGizmoManager 一致) ───── querySelectNodes(): Node[] { diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/gizmo-persistent.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/gizmo-persistent.ts new file mode 100644 index 000000000..f9cc4cc71 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/gizmo-persistent.ts @@ -0,0 +1,44 @@ +import { Terrain } from 'cc'; +import GizmoBase from '../../base/gizmo-base'; +import TerrainController from '../../controller/terrain'; +import type { GizmoMouseEvent } from '../../utils/defines'; +import { Service } from '../../../core/decorator'; +import { getEditorNodePath } from '../../utils/editor-node'; +import type TerrainGizmo from './gizmo-select'; + +/** Persistent gizmo receiving mouse input over the non-raycast Terrain component. */ +export default class TerrainPersistentGizmo extends GizmoBase { + private _controller!: TerrainController; + private get selectGizmo(): TerrainGizmo | null { + return this.target ? Service.Gizmo.getComponentGizmo(this.target) as TerrainGizmo | null : null; + } + protected init() { + this._controller = new TerrainController(this.getGizmoRoot()); + this._controller.onControllerMouseDown = this.onControllerMouseDown.bind(this); + this._controller.onControllerMouseMove = this.onControllerMouseMove.bind(this); + this._controller.onControllerMouseUp = this.onControllerMouseUp.bind(this); + this._controller.onControllerHoverOut = this.onControllerHoverOut.bind(this); + this.updateController(); + } + private updateController() { + if (!this._controller) return; + if (!this.target) { this._controller.hide(); return; } + this._controller.updateWorldPosition(this.target.node.getWorldPosition()); + const info = this.target.info; + this._controller.updateSize(info.size.width, info.size.height); + this._controller.show(); Service.Engine.repaintInEditMode(); + } + public onTargetUpdate() { this.updateController(); } + public onNodeChanged() { this.updateController(); } + onControllerMouseDown(event: GizmoMouseEvent) { + if (!this.target) return; + const path = getEditorNodePath(this.target.node); + if (Service.Selection.query()[0] !== path) { + event.propagationStopped = true; Service.Selection.select(path); return; + } + const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerMouseDown(event); + } + onControllerMouseMove(event: GizmoMouseEvent) { const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerMouseMove(event); } + onControllerMouseUp(event: GizmoMouseEvent) { const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerMouseUp(event); } + onControllerHoverOut() { const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerHoverOut(); } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/gizmo-select.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/gizmo-select.ts new file mode 100644 index 000000000..ce715d155 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/gizmo-select.ts @@ -0,0 +1,150 @@ +import { Terrain, TerrainInfo, TerrainLayer, TERRAIN_MAX_LAYER_COUNT, assetManager } from 'cc'; +import { Service } from '../../../core/decorator'; +import { loadAny } from '../../../node/node-create'; +import GizmoBase from '../../base/gizmo-base'; +import { TerrainEditor } from './terrain-editor'; +import { eTerrainEditorMode } from './terrain-editor-mode'; +import { TerrainBrushType, TerrainImageBrush } from './terrain-brush'; +import type { GizmoMouseEvent } from '../../utils/defines'; + +interface IBrush { radius: number; strength: number; _setHeight: number; } +interface ITerrainInfo { tileSize: number; weightMapSize: number; lightMapSize: number; blockCount: number[]; } + +/** Component gizmo containing all Terrain editing operations. */ +export default class TerrainGizmo extends GizmoBase { + private _editor!: TerrainEditor; + private _isEditorInit = false; + private _isShiftDown = false; + private _isConcave = false; + private _isSmooth = false; + private _isFlatten = false; + private _isSetHeight = false; + + public get editor() { return this._editor; } + public get isConcave() { return this._isConcave; } + public get isSmooth() { return this._isSmooth; } + public get isFlatten() { return this._isFlatten; } + public get isSetHeight() { return this._isSetHeight; } + public applySmooth(value: boolean) { this._isSmooth = value; } + public get isTerrainChange() { return !!this.target && Service.Terrain.isTerrainChange; } + public set isTerrainChange(value: boolean) { + if (this.target) { (this.target as any).manager = Service.Terrain; (this.target as any).isTerrainChange = value; } + if (value && this.target) Service.Terrain.select(this.target.node.uuid); + } + + protected init() { + this._editor = new TerrainEditor((Service.Camera as any).getCamera?.() ?? null, this); + } + protected onShow() { + this.registerCameraMovedEvent(); this.initEditor(); this._editor.updateBlockDepthOffset(); + } + protected onHide() { + this._isEditorInit = false; this.unregisterCameraMoveEvent(); + this._editor?.clearBrush(); this._editor?.setEditTerrain(null); this._editor?.setCurrentLayer(0); + Service.Engine.repaintInEditMode(); + } + public onTargetUpdate() { if (this._isInitialized) this.initEditor(); } + public onNodeChanged() { if (this._isInitialized) this.initEditor(); } + public onEditorCameraMoved() { this._editor?.updateBlockDepthOffset(); } + private initEditor() { + if (!this._isEditorInit && this._editor) { + this._editor.setEditTerrain(this.target); this._isEditorInit = true; Service.Engine.repaintInEditMode(); + } + } + + async addLayerByUuid(uuid: string) { + if (!this.target) return -1; + const texture = await loadAny(uuid); + const layer = new TerrainLayer(); layer.detailMap = texture; layer.tileSize = 1; + const index = this.target.addLayer(layer); this.updateTerrainAsset(); this.isTerrainChange = true; this.emitNodeChange(); + Service.Engine.repaintInEditMode(); return index; + } + async setSculptBrush(uuid: string) { return this.setBrushImage(eTerrainEditorMode.SCULPT, uuid); } + async setPaintBrush(uuid: string) { return this.setBrushImage(eTerrainEditorMode.PAINT, uuid); } + private async setBrushImage(mode: eTerrainEditorMode, uuid: string) { + const editMode: any = this._editor.getMode(mode); + if (!uuid) { editMode.setBrushImage(null); return true; } + const texture = await loadAny(uuid); + const imageBrush = editMode.getBrush(TerrainBrushType.IMAGE) as TerrainImageBrush; + if (imageBrush.image !== texture) editMode.setBrushImage(texture); + this.isTerrainChange = true; Service.Engine.repaintInEditMode(); return true; + } + async setSculptBrushRotation(rotation: number) { + (this._editor.getMode(eTerrainEditorMode.SCULPT) as any).setSculptBrushRotation(rotation); + } + async setLayerValue(index: number, uuid: string, extVal: any) { + if (!this.target) return null; + const layer = this.target.getLayer(index); if (!layer) return null; + if (extVal) { + if ('tileSize' in extVal) layer.tileSize = extVal.tileSize; + if ('metallic' in extVal) layer.metallic = extVal.metallic; + if ('roughness' in extVal) layer.roughness = extVal.roughness; + if ('normalMap' in extVal) layer.normalMap = extVal.normalMap ? await loadAny(extVal.normalMap) : null; + } + if (uuid) layer.detailMap = await loadAny(uuid); + this.updateTerrainAsset(); this.isTerrainChange = true; this.emitNodeChange(); Service.Engine.repaintInEditMode(); + return index; + } + removeLayerByIndex(index: number) { + if (!this.target) return; + this.target.removeLayer(index); this.updateTerrainAsset(); this.isTerrainChange = true; this.emitNodeChange(); Service.Engine.repaintInEditMode(); + } + setCurrentEditLayer(index: number) { this._editor.setCurrentLayer(index); Service.Engine.repaintInEditMode(); } + getLayers() { + if (!this.target) return []; + return Array.from({ length: TERRAIN_MAX_LAYER_COUNT }, (_, index) => { + const layer = this.target!.getLayer(index); + return layer ? { + detailMap: layer.detailMap?._uuid ?? null, metallic: layer.metallic, + normalMap: layer.normalMap?._uuid ?? null, roughness: layer.roughness, tileSize: layer.tileSize, + } : null; + }); + } + getCurrentEditLayer() { return this._editor.getCurrentLayer(); } + setCurrentEditMode(mode: eTerrainEditorMode, option?: any) { + const config = Object.assign({ isSculptDown: false, isSmooth: false, isFlatten: false, isSetHeight: false }, option || {}); + this._isConcave = this._isShiftDown = !!config.isSculptDown; this._isSmooth = !!config.isSmooth; + this._isFlatten = !!config.isFlatten; this._isSetHeight = !!config.isSetHeight; + this._editor.setMode(mode); Service.Engine.repaintInEditMode(); + } + queryTerrainInfo(): ITerrainInfo | null { + const info = this.target?.info; return info ? { + tileSize: info.tileSize, weightMapSize: info.weightMapSize, lightMapSize: info.lightMapSize, blockCount: [...info.blockCount], + } : null; + } + changeTerrainInfo(info: any) { + if (!this.target) return; + const terrainInfo = new TerrainInfo(); Object.assign(terrainInfo, info); this.target.rebuild(terrainInfo); + this.isTerrainChange = true; this.emitNodeChange(); Service.Engine.repaintInEditMode(); + } + queryBrushOfMode(mode: eTerrainEditorMode): IBrush | null { + if (mode !== eTerrainEditorMode.SCULPT && mode !== eTerrainEditorMode.PAINT) return null; + const brush = (this._editor.getMode(mode) as any).getCurrentBrush(); + return { radius: brush.radius, strength: brush.strength, _setHeight: brush._setHeight }; + } + setBrushOfMode(mode: eTerrainEditorMode, setting: any) { + if (mode !== eTerrainEditorMode.SCULPT && mode !== eTerrainEditorMode.PAINT) return; + const brush = (this._editor.getMode(mode) as any).getCurrentBrush(); + for (const key of Object.keys(setting || {})) if (key !== 'material' && setting[key] !== undefined) brush[key] = setting[key]; + } + getBlockInfo() { + const mode = this._editor.getMode(eTerrainEditorMode.SELECT); const index = mode.getCurrentBlockIndex() ?? [0, 0]; + const weight = mode.getCurrentWeightData(); + return { + index: { x: index[0], y: index[1] }, + weight: weight ? { data: Array.from(weight.data), width: weight.width, height: weight.height } : null, + layers: mode.getCurrentLayerList().map((layer: any) => layer?._uuid ?? ''), + }; + } + emitNodeChange() { if (this.target) this.onComponentChanged(this.target.node); } + onKeyDown(event: any) { if (event.shiftKey) this._isShiftDown = true; } + onKeyUp(event: any) { if (event.keyCode === 16) this._isShiftDown = this._isConcave; } + onUpdate(deltaTime: number) { this._editor?.update(deltaTime, this._isShiftDown); } + onCameraControlModeChanged(mode: number) { if (mode !== 0 && this._editor?.isChanged) this.emitNodeChange(); } + updateTerrainAsset() { if (this.target?._asset) this.target.exportLayerListToAsset(this.target._asset); } + + public onControllerMouseDown(event: GizmoMouseEvent) { event.propagationStopped = true; this._isShiftDown = event.shiftKey; this._editor.onMouseDown(event.x, event.y); } + public onControllerMouseMove(event: GizmoMouseEvent) { event.propagationStopped = true; this._editor.onMouseMove(event.x, event.y); } + public onControllerMouseUp(event: GizmoMouseEvent) { event.propagationStopped = true; if (this._editor.isChanged) this.emitNodeChange(); this._editor.onMouseUp(); } + public onControllerHoverOut() { this._editor.onHoverOut(); } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/index.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/index.ts new file mode 100644 index 000000000..5766d62c8 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/index.ts @@ -0,0 +1,13 @@ +'use strict'; + +import { js, Terrain } from 'cc'; +import { registerGizmo } from '../../gizmo-defines'; +import TerrainGizmo from './gizmo-select'; +import TerrainPersistentGizmo from './gizmo-persistent'; + +export const name = js.getClassName(Terrain); +export const SelectGizmo = TerrainGizmo; +export const IconGizmo = null; +export const PersistentGizmo = TerrainPersistentGizmo; + +registerGizmo(name, { SelectGizmo, PersistentGizmo }); diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-brush.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-brush.ts new file mode 100644 index 000000000..a9a6ac432 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-brush.ts @@ -0,0 +1,204 @@ +import { Material, Terrain, Texture2D, Vec2, Vec3, Vec4, builtinResMgr, clamp } from 'cc'; + +export enum TerrainBrushType { CIRCLE, IMAGE, _MAX } + +export class TerrainEdModifierKeyState { public siftPressed = false; } + +const brushDepthOffsetDefaultRatios = 0.001; +let brushDepthOffset = 0.05; + +/** Shared brush math and the editor brush preview material state. */ +export class TerrainBrush { + public static updateBrushDepthOffset(cameraDistanceFactor: number) { + const ratios = brushDepthOffsetDefaultRatios + (cameraDistanceFactor / 300) * 0.00025; + brushDepthOffset = Math.max(0.05, cameraDistanceFactor * ratios); + } + + public static updateBrushDepthOffsetToMaterial(material: Material | null) { + material?.setProperty('BrushDepthOffset', brushDepthOffset); + } + + public material: Material | null = null; + public position = new Vec3(); + public radius = 5; + public strength = 1; + public _setHeight = 0; + public _rotation = 0; + + public get rotation() { return this._rotation / 180 * Math.PI; } + public getDelta(_x: number, _z: number) { return 0; } + public getBound(bbmin: Vec2, bbmax: Vec2) { + bbmin.set(this.position.x - this.radius, this.position.z - this.radius); + bbmax.set(this.position.x + this.radius, this.position.z + this.radius); + } + public update(_terrain: Terrain, pos: Vec3) { this.position.set(pos); } +} + +export class TerrainBrushData { + public bmin: number[] = [0, 0]; + public bmax: number[] = [0, 0]; + public width() { return this.bmax[0] - this.bmin[0] + 1; } + public height() { return this.bmax[1] - this.bmin[1] + 1; } +} + +export enum eTerrainCircleBrushType { Linear, Smooth, Spherical, Tip } + +export class TerrainCircleBrush extends TerrainBrush { + protected type = eTerrainCircleBrushType.Linear; + protected falloff = 0.5; + + constructor() { super(); this._updateMaterial(); } + + public setType(type: eTerrainCircleBrushType) { + if (this.type !== type) { this.type = type; this._updateMaterial(); } + } + public getType() { return this.type; } + public _updateMaterial() { + const effect = (cc as any).EffectAsset?.get?.('internal/editor/terrain-circle-brush'); + if (effect) { + this.material = new Material(); + this.material.initialize({ effectAsset: effect, defines: this._getTypeDefine() }); + } + } + public _getTypeDefine(): Record { + return [{ LINEAR: 1 }, { SMOOTH: 1 }, { SPHERICAL: 1 }, { TIP: 1 }][this.type]; + } + public static _calculateFalloff_Linear(distance: number, radius: number, falloff: number) { + if (distance <= radius) return 1; + if (distance > radius + falloff || falloff <= 0) return 0; + return Math.max(0, 1 - (distance - radius) / falloff); + } + public static _calculateFalloff_Spherical(distance: number, radius: number, falloff: number) { + const y = this._calculateFalloff_Linear(distance, radius, falloff); + return y * y * (3 - 2 * y); + } + public static _calculateFalloff_Smooth(distance: number, radius: number, falloff: number) { + if (distance <= radius) return 1; + if (distance > radius + falloff || falloff <= 0) return 0; + const y = (distance - radius) / falloff; + return Math.sqrt(Math.max(0, 1 - y * y)); + } + public static _calculateFalloff_Tip(distance: number, radius: number, falloff: number) { + if (distance <= radius) return 1; + if (distance > radius + falloff || falloff <= 0) return 0; + const y = (falloff + radius - distance) / falloff; + return 1 - Math.sqrt(Math.max(0, 1 - y * y)); + } + public getDelta(x: number, z: number) { + const distance = Math.hypot(x - this.position.x, z - this.position.z); + const radius = (1 - this.falloff) * this.radius; + const falloff = this.falloff * this.radius; + let value = 0; + switch (this.type) { + case eTerrainCircleBrushType.Linear: value = TerrainCircleBrush._calculateFalloff_Linear(distance, radius, falloff); break; + case eTerrainCircleBrushType.Smooth: value = TerrainCircleBrush._calculateFalloff_Smooth(distance, radius, falloff); break; + case eTerrainCircleBrushType.Spherical: value = TerrainCircleBrush._calculateFalloff_Spherical(distance, radius, falloff); break; + case eTerrainCircleBrushType.Tip: value = TerrainCircleBrush._calculateFalloff_Tip(distance, radius, falloff); break; + } + return value * this.strength; + } + public update(terrain: Terrain, pos: Vec3) { + super.update(terrain, pos); + if (!this.material) return; + const terrainPos = terrain.node.getWorldPosition(); + const brushPos = new Vec4(terrainPos.x + pos.x, terrainPos.y + pos.y, terrainPos.z + pos.z, 0); + const brushParams = new Vec4((1 - this.falloff) * this.radius, this.falloff * this.radius, 0, 0); + for (const block of terrain.getBlocks()) { + if (block._getBrushMaterial() !== this.material || !block._getBrushPass() || !block.material) continue; + block.material.setProperty('BrushPos', brushPos); + block.material.setProperty('BrushParams', brushParams); + TerrainBrush.updateBrushDepthOffsetToMaterial(block.material); + } + } +} + +export class TerrainImageBrush extends TerrainBrush { + private _image: Texture2D | null = null; + private _pixelData: number[] | null = null; + + constructor() { + super(); + const effect = (cc as any).EffectAsset?.get?.('internal/editor/terrain-image-brush'); + if (effect) { + this.material = new Material(); + this.material.initialize({ effectAsset: effect }); + } + } + + public set image(value: Texture2D | null) { + if (this._image === value) return; + this._image = value; + this._pixelData = null; + if (!value || typeof document === 'undefined') return; + const nativeData: any = value.mipmaps?.[0]?.data; + const source = nativeData?._src; + const readImage = (image: CanvasImageSource, width: number, height: number) => { + const canvas = document.createElement('canvas'); + canvas.width = width; canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) return; + context.drawImage(image, 0, 0, width, height); + const data = context.getImageData(0, 0, width, height).data; + this._pixelData = new Array(width * height); + for (let i = 0; i < this._pixelData.length; ++i) this._pixelData[i] = data[i * 4] / 255; + }; + if (source) { + const image = document.createElement('img'); + image.onload = () => readImage(image, value.width, value.height); + image.src = `file://${source}`; + } else if (nativeData) { + readImage(nativeData as CanvasImageSource, value.width, value.height); + } + } + public get image() { return this._image; } + public static getColor(pixels: number[], width: number, height: number, u: number, v: number) { + u = clamp(u, 0, width - 1); v = clamp(v, 0, height - 1); + return pixels[v * width + u]; + } + public static sampleImage(pixels: number[], width: number, height: number, u: number, v: number) { + u *= width - 1; v *= height - 1; + const u0 = Math.floor(u), v0 = Math.floor(v), u1 = u0 + 1, v1 = v0 + 1; + const du = u - u0, dv = v - v0; + const c00 = this.getColor(pixels, width, height, u0, v0); + const c10 = this.getColor(pixels, width, height, u1, v0); + const c01 = this.getColor(pixels, width, height, u0, v1); + const c11 = this.getColor(pixels, width, height, u1, v1); + return (c00 + (c10 - c00) * du) * (1 - dv) + (c01 + (c11 - c01) * du) * dv; + } + public sample(u: number, v: number) { + return this._pixelData && this._image + ? TerrainImageBrush.sampleImage(this._pixelData, this._image.width, this._image.height, u, v) + : 1; + } + public getDelta(x: number, z: number) { + let dx = this.position.x - x, dz = this.position.z - z; + if (this.rotation) { + const sine = Math.sin(this.rotation), cosine = Math.cos(this.rotation); + const tx = dx * cosine + dz * sine; + dz = -dx * sine + dz * cosine; dx = tx; + } + const u = dx / this.radius * 0.5 + 0.5, v = dz / this.radius * 0.5 + 0.5; + return u < 0 || u > 1 || v < 0 || v > 1 ? 0 : this.sample(u, v) * this.strength; + } + public getBound(bbmin: Vec2, bbmax: Vec2) { + const c = Math.abs(Math.cos(this.rotation)), s = Math.abs(Math.sin(this.rotation)); + const halfX = this.radius * (c + s), halfZ = this.radius * (c + s); + bbmin.set(this.position.x - halfX, this.position.z - halfZ); + bbmax.set(this.position.x + halfX, this.position.z + halfZ); + } + public update(terrain: Terrain, pos: Vec3) { + super.update(terrain, pos); + if (!this.material) return; + const terrainPos = terrain.node.getWorldPosition(); + const brushPos = new Vec4(terrainPos.x + pos.x, terrainPos.y + pos.y, terrainPos.z + pos.z, 0); + const brushParams = new Vec4(this.radius, 1, this.rotation, 0); + const fallback = builtinResMgr.get('grey-texture'); + for (const block of terrain.getBlocks()) { + if (block._getBrushMaterial() !== this.material || !block._getBrushPass() || !block.material) continue; + block.material.setProperty('BrushPos', brushPos); + block.material.setProperty('BrushParams', brushParams); + block.material.setProperty('BrushImage', this._image ?? fallback); + TerrainBrush.updateBrushDepthOffsetToMaterial(block.material); + } + } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-manage.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-manage.ts new file mode 100644 index 000000000..c140eca6f --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-manage.ts @@ -0,0 +1,2 @@ +import { TerrainEditorMode } from './terrain-editor-mode'; +export class TerrainEditorManage extends TerrainEditorMode {} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-mode.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-mode.ts new file mode 100644 index 000000000..41d4c0641 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-mode.ts @@ -0,0 +1,13 @@ +import type { Terrain } from 'cc'; + +export enum eTerrainEditorMode { MANAGE, SCULPT, PAINT, SELECT } + +export class TerrainEditorMode { + protected _gizmo: any; + constructor(gizmo: any) { this._gizmo = gizmo; } + get gizmo() { return this._gizmo; } + public onUpdate(_terrain: Terrain, _dTime: number, _isShiftDown: boolean) {} + public onActivate() {} + public onDeactivate() {} + public forceUpdate() {} +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-paint.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-paint.ts new file mode 100644 index 000000000..c844413d9 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-paint.ts @@ -0,0 +1,107 @@ +import { Rect, Terrain, TERRAIN_BLOCK_TILE_COMPLEXITY, Texture2D, Vec3, Vec4, math } from 'cc'; +import { Service } from '../../../core/decorator'; +import { TerrainBrush, TerrainBrushType, TerrainCircleBrush, TerrainImageBrush } from './terrain-brush'; +import { TerrainEditorMode } from './terrain-editor-mode'; +import { TerrainWeightOperation, TerrainWeightUndoRedo } from './terrain-operation'; + +const clamp = math.clamp; + +export class TerrainEditorPaint extends TerrainEditorMode { + public _brushes: TerrainBrush[]; + public _undo: TerrainWeightUndoRedo | null = null; + public _currentLayer = -1; + public _currentBrush: TerrainBrush; + + constructor(gizmo: any) { + super(gizmo); + const circle = new TerrainCircleBrush(); circle.strength = 5; + const image = new TerrainImageBrush(); image.strength = 5; + this._brushes = [circle, image]; this._currentBrush = circle; + } + public setCurrentBrush(type: TerrainBrushType) { + const old = this._currentBrush; this._currentBrush = this._brushes[type]; + this._currentBrush.position.set(old.position); this._currentBrush.radius = old.radius; + this._currentBrush.strength = old.strength; this._currentBrush._setHeight = old._setHeight; + this._currentBrush._rotation = old._rotation; + } + public getCurrentBrush() { return this._currentBrush; } + public getBrush(type: TerrainBrushType) { return this._brushes[type]; } + public setBrushImage(texture: Texture2D | null) { + const image = this.getBrush(TerrainBrushType.IMAGE) as TerrainImageBrush; + image.image = texture; this.setCurrentBrush(texture ? TerrainBrushType.IMAGE : TerrainBrushType.CIRCLE); + } + public setCurrentLayer(layer: number) { this._currentLayer = layer; } + public getCurrentLayer() { return this._currentLayer; } + + public onUpdate(terrain: Terrain, deltaTime: number) { + if (!this._undo) return; + this._updateWeight(terrain, deltaTime); this.gizmo.isTerrainChange = true; + } + public onUpdateBrushPosition(terrain: Terrain, position: Vec3) { + const brush = this._currentBrush; brush.update(terrain, position); + const brushRect = new Rect(position.x - brush.radius, position.z - brush.radius, brush.radius * 2, brush.radius * 2); + for (const block of terrain.getBlocks()) { + const index = block.getIndex(); + const bound = new Rect( + index[0] * TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + index[1] * TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + ); + block.setBrushMaterial(bound.intersects(brushRect) ? brush.material : null); + } + } + public onMouseDown(terrain: Terrain) { + if (this._currentLayer !== -1) this._undo = new TerrainWeightUndoRedo(terrain); + } + public onMouseUp() { + if (this._undo?.data.length || this._undo?.redoOperations.length) Service.Undo.push(this._undo); + this._undo = null; + } + public forceUpdate() { TerrainBrush.updateBrushDepthOffsetToMaterial(this._currentBrush.material); } + public onDeactivate() { + if (!this.gizmo.editor.getEditTerrain()?._asset) this._currentLayer = -1; + } + + private _updateWeight(terrain: Terrain, deltaTime: number) { + const width = terrain.info.weightMapSize * terrain.info.blockCount[0]; + const height = terrain.info.weightMapSize * terrain.info.blockCount[1]; + if (!width || !height) return; + const brush = this._currentBrush; + let x1 = Math.floor((brush.position.x - brush.radius) / terrain.info.size.width * (width - 1)); + let y1 = Math.floor((brush.position.z - brush.radius) / terrain.info.size.height * (height - 1)); + let x2 = Math.floor((brush.position.x + brush.radius) / terrain.info.size.width * (width - 1)); + let y2 = Math.floor((brush.position.z + brush.radius) / terrain.info.size.height * (height - 1)); + if (x1 > width - 1 || x2 < 0 || y1 > height - 1 || y2 < 0) return; + x1 = clamp(x1, 0, width - 1); y1 = clamp(y1, 0, height - 1); x2 = clamp(x2, 0, width - 1); y2 = clamp(y2, 0, height - 1); + const operation = new TerrainWeightOperation(terrain); this._undo?.redoOperations.push(operation); + for (let y = y1; y <= y2; ++y) { + for (let x = x1; x <= x2; ++x) { + const weight = terrain.getWeight(x, y); + const block = terrain.getBlock(Math.floor(x / terrain.info.weightMapSize), Math.floor(y / terrain.info.weightMapSize)); + if (!block) continue; + const layers = [...block.layers]; + const delta = brush.getDelta( + x / (width - 1) * terrain.info.size.width, + y / (height - 1) * terrain.info.size.height, + ) * deltaTime; + if (!delta) continue; + const layerSlot = layers.indexOf(this._currentLayer); + if (layerSlot >= 0) { + (weight as any)[['x', 'y', 'z', 'w'][layerSlot]] += delta; + } else { + const emptySlot = layers.indexOf(-1); + if (emptySlot < 0) continue; + block.setLayer(emptySlot, this._currentLayer); + (weight as any)[['x', 'y', 'z', 'w'][emptySlot]] += delta; + } + const sum = weight.x + weight.y + weight.z + weight.w; + if (sum > 0) weight.multiplyScalar(1 / sum); + this._undo?.push(x, y, terrain.getWeight(x, y)); + this._undo?.pushBlock(block, layers, [...block.layers]); + operation.push(x, y, weight); + } + } + operation.apply(); + } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-sculpt-tools.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-sculpt-tools.ts new file mode 100644 index 000000000..2f8f047f2 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-sculpt-tools.ts @@ -0,0 +1,32 @@ +import type { Terrain } from 'cc'; +import { TerrainEdModifierKeyState } from './terrain-brush'; + +export enum eTerrainTerrainEditorSculptToolMode { SCULPT, SMOOTH, FLATTEN, SET_HEIGHT } + +export class TerrainEditorSculptTool { + start(_terrain: Terrain, _x: number, _y: number) {} + apply(_terrain: Terrain, _x: number, _y: number, h: number, _delta: number, _modifiers: TerrainEdModifierKeyState) { return h; } +} +export class TerrainEditorSculptTool_Sculpt extends TerrainEditorSculptTool { + constructor(public _concave: boolean) { super(); } + apply(_terrain: Terrain, _x: number, _y: number, h: number, delta: number, modifiers: TerrainEdModifierKeyState) { + return h + (this._concave || modifiers.siftPressed ? -delta : delta); + } +} +export class TerrainEditorSculptTool_Smooth extends TerrainEditorSculptTool { + apply(terrain: Terrain, x: number, y: number, h: number, delta: number) { + const average = (terrain.getHeightClamp(x - 1, y - 1) + h + terrain.getHeightClamp(x + 1, y + 1)) / 3; + return h + delta * 3 * (average - h); + } +} +export class TerrainEditorSculptTool_Flatten extends TerrainEditorSculptTool { + protected _height = 0; + start(terrain: Terrain, x: number, y: number) { this._height = terrain.getHeightClamp(x, y); } + apply(_terrain: Terrain, _x: number, _y: number, h: number, delta: number) { + return h > this._height ? Math.max(h - delta, this._height) : Math.min(h + delta, this._height); + } +} +export class TerrainEditorSculptTool_SetHeight extends TerrainEditorSculptTool_Flatten { + constructor(height: number) { super(); this._height = height; } + start(_terrain: Terrain, _x: number, _y: number) {} +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-sculpt.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-sculpt.ts new file mode 100644 index 000000000..97e60f13a --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-sculpt.ts @@ -0,0 +1,111 @@ +import { Rect, Terrain, TERRAIN_BLOCK_TILE_COMPLEXITY, Texture2D, Vec2, Vec3, math } from 'cc'; +import { Service } from '../../../core/decorator'; +import { + TerrainBrush, TerrainBrushType, TerrainCircleBrush, TerrainEdModifierKeyState, TerrainImageBrush, +} from './terrain-brush'; +import { TerrainEditorMode } from './terrain-editor-mode'; +import { + eTerrainTerrainEditorSculptToolMode, TerrainEditorSculptTool, TerrainEditorSculptTool_Flatten, + TerrainEditorSculptTool_Sculpt, TerrainEditorSculptTool_SetHeight, TerrainEditorSculptTool_Smooth, +} from './terrain-editor-sculpt-tools'; +import { TerrainHeightOperation, TerrainHeightUndoRedo } from './terrain-operation'; + +const clamp = math.clamp; + +export class TerrainEditorSculpt extends TerrainEditorMode { + public _brushes: TerrainBrush[]; + public _undo: TerrainHeightUndoRedo | null = null; + public _currentBrush: TerrainBrush; + private _currentTool: TerrainEditorSculptTool | null = null; + + constructor(gizmo: any) { + super(gizmo); + const circle = new TerrainCircleBrush(); circle.strength = 5; + const image = new TerrainImageBrush(); image.strength = 5; + this._brushes = [circle, image]; this._currentBrush = circle; + } + + public setCurrentBrush(type: TerrainBrushType) { + const old = this._currentBrush; + this._currentBrush = this._brushes[type]; + this._currentBrush.position.set(old.position); + this._currentBrush.radius = old.radius; + this._currentBrush.strength = old.strength; + this._currentBrush._setHeight = old._setHeight; + } + public getCurrentBrush() { return this._currentBrush; } + public getBrush(type: TerrainBrushType) { return this._brushes[type]; } + public setBrushImage(texture: Texture2D | null) { + const imageBrush = this.getBrush(TerrainBrushType.IMAGE) as TerrainImageBrush; + imageBrush.image = texture; this.setCurrentBrush(texture ? TerrainBrushType.IMAGE : TerrainBrushType.CIRCLE); + } + public setSculptBrushRotation(rotation: number) { (this.getBrush(TerrainBrushType.IMAGE) as TerrainImageBrush)._rotation = rotation; } + + public onUpdate(terrain: Terrain, deltaTime: number, isShiftDown: boolean) { + if (!this._currentTool) return; + const modifiers = new TerrainEdModifierKeyState(); modifiers.siftPressed = isShiftDown; + this._updateHeight(terrain, deltaTime, modifiers); + this.gizmo.isTerrainChange = true; + } + public forceUpdate() { TerrainBrush.updateBrushDepthOffsetToMaterial(this._currentBrush.material); } + + public onUpdateBrushPosition(terrain: Terrain, position: Vec3) { + const brush = this._currentBrush; brush.update(terrain, position); + const bbmin = new Vec2(), bbmax = new Vec2(); brush.getBound(bbmin, bbmax); + const brushRect = new Rect(bbmin.x, bbmin.y, bbmax.x - bbmin.x, bbmax.y - bbmin.y); + for (const block of terrain.getBlocks()) { + const index = block.getIndex(); + const bound = new Rect( + index[0] * TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + index[1] * TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + ); + block.setBrushMaterial(bound.intersects(brushRect) ? brush.material : null); + } + } + + public onMouseDown(terrain: Terrain) { + this._undo = new TerrainHeightUndoRedo(terrain); + let mode = eTerrainTerrainEditorSculptToolMode.SCULPT; + if (this.gizmo.isSmooth) mode = eTerrainTerrainEditorSculptToolMode.SMOOTH; + else if (this.gizmo.isFlatten) mode = eTerrainTerrainEditorSculptToolMode.FLATTEN; + else if (this.gizmo.isSetHeight) mode = eTerrainTerrainEditorSculptToolMode.SET_HEIGHT; + switch (mode) { + case eTerrainTerrainEditorSculptToolMode.SMOOTH: this._currentTool = new TerrainEditorSculptTool_Smooth(); break; + case eTerrainTerrainEditorSculptToolMode.FLATTEN: this._currentTool = new TerrainEditorSculptTool_Flatten(); break; + case eTerrainTerrainEditorSculptToolMode.SET_HEIGHT: this._currentTool = new TerrainEditorSculptTool_SetHeight(this._currentBrush._setHeight); break; + default: this._currentTool = new TerrainEditorSculptTool_Sculpt(this.gizmo.isConcave); break; + } + const x = Math.floor(this._currentBrush.position.x / terrain.info.tileSize); + const y = Math.floor(this._currentBrush.position.z / terrain.info.tileSize); + this._currentTool.start(terrain, x, y); + } + + public onMouseUp() { + if (this._undo?.data.length || this._undo?.redoOperations.length) Service.Undo.push(this._undo); + this._undo = null; this._currentTool = null; + } + + public _updateHeight(terrain: Terrain, deltaTime: number, modifiers: TerrainEdModifierKeyState) { + if (!this._currentTool) return; + const bbmin = new Vec2(), bbmax = new Vec2(); this._currentBrush.getBound(bbmin, bbmax); + let x1 = Math.floor(bbmin.x / terrain.info.tileSize), y1 = Math.floor(bbmin.y / terrain.info.tileSize); + let x2 = Math.floor(bbmax.x / terrain.info.tileSize), y2 = Math.floor(bbmax.y / terrain.info.tileSize); + const maxX = terrain.info.vertexCount[0] - 1, maxY = terrain.info.vertexCount[1] - 1; + if (x1 > maxX || x2 < 0 || y1 > maxY || y2 < 0) return; + x1 = clamp(x1, 0, maxX); y1 = clamp(y1, 0, maxY); x2 = clamp(x2, 0, maxX); y2 = clamp(y2, 0, maxY); + const operation = new TerrainHeightOperation(terrain); this._undo?.redoOperations.push(operation); + for (let y = y1; y <= y2; ++y) { + for (let x = x1; x <= x2; ++x) { + let height = terrain.getHeightClamp(x, y); + this._undo?.push(x, y, height); + const delta = this._currentBrush.getDelta(x * terrain.info.tileSize, y * terrain.info.tileSize) * deltaTime; + height = this._currentTool.apply(terrain, x, y, height, delta, modifiers); + operation.push(x, y, height); + } + } + operation.apply(); + Service.Terrain?.onSculpt(terrain.node); + } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-select.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-select.ts new file mode 100644 index 000000000..2512925e1 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor-select.ts @@ -0,0 +1,87 @@ +import { + Camera, Material, Rect, Terrain, TerrainBlock, TERRAIN_BLOCK_TILE_COMPLEXITY, Texture2D, Vec2, Vec3, clamp, +} from 'cc'; +import { ServiceEvents } from '../../../core/global-events'; +import { TerrainBrush } from './terrain-brush'; +import { TerrainEditorMode } from './terrain-editor-mode'; + +export class TerrainEditorWeightMapData { + public data = new Uint8Array(); + public width = 0; + public height = 0; +} + +/** Block picker and read-only data provider for the terrain float window. */ +export class TerrainEditorSelect extends TerrainEditorMode { + private _selectMaterial: Material | null = null; + private _selectBlock: TerrainBlock | null = null; + private _weightMap: Texture2D | null = null; + private _weightData: TerrainEditorWeightMapData | null = null; + private _layerList: Array = []; + + constructor(gizmo: any) { + super(gizmo); + const effect = (cc as any).EffectAsset?.get?.('internal/editor/terrain-select-brush'); + if (effect) { this._selectMaterial = new Material(); this._selectMaterial.initialize({ effectAsset: effect }); } + } + public setSelectBlock(block: TerrainBlock | null) { + if (this._selectBlock === block) return; + ServiceEvents.emit('terrain:block-update'); + if (this._selectBlock) this._updateBlockSelectMaterial(this._selectBlock, null); + this._selectBlock = block; + this._weightMap = null; this._weightData = null; this._layerList = []; + if (!block) return; + const terrain = block.getTerrain(); this._weightMap = block.weightmap; + if (this._weightMap) { + const size = terrain.info.weightMapSize; + this._weightData = new TerrainEditorWeightMapData(); + this._weightData.width = size; this._weightData.height = size; + this._weightData.data = new Uint8Array(size * size * 4); + const index = block.getIndex(); let offset = 0; + for (let y = index[1] * size; y < (index[1] + 1) * size; ++y) { + for (let x = index[0] * size; x < (index[0] + 1) * size; ++x) { + const weight = terrain.getWeight(x, y); + this._weightData.data[offset++] = clamp(weight.x * 255, 0, 255); + this._weightData.data[offset++] = clamp(weight.y * 255, 0, 255); + this._weightData.data[offset++] = clamp(weight.z * 255, 0, 255); + this._weightData.data[offset++] = clamp(weight.w * 255, 0, 255); + } + } + } + this._layerList = block.layers.map((layerId) => layerId >= 0 ? terrain.getLayer(layerId)?.detailMap ?? null : null); + this._updateBlockSelectMaterial(block, this._selectMaterial); + } + public getSelectBlock() { return this._selectBlock; } + public getCurrentBlockIndex() { return this._selectBlock?.getIndex() ?? null; } + public getCurrentWeightMap() { return this._weightMap; } + public getCurrentWeightData() { return this._weightData; } + public getCurrentLayerList() { return this._layerList; } + public onDeactivate() { this.setSelectBlock(null); } + public forceUpdate() { + if (!this._selectBlock) return; + TerrainBrush.updateBrushDepthOffsetToMaterial(this._selectMaterial); + this._selectBlock.setBrushMaterial(this._selectMaterial); this._selectBlock._invalidMaterial(); + } + public onMouseDown(terrain: Terrain, camera: Camera, x: number, y: number) { + const from = camera.node.getWorldPosition(); const screen = new Vec3(x, y, 0); const to = new Vec3(); + camera.screenToWorld(screen, to); const direction = new Vec3(); Vec3.subtract(direction, to, from).normalize(); + const hit = terrain.rayCheck(from, direction, 0.35, true); + if (!hit) return; + // Terrain.rayCheck returns coordinates in Terrain local space even when + // the input ray is world-space. + const picked = new Vec2(hit.x, hit.z); + const block = terrain.getBlocks().find((candidate) => { + const index = candidate.getIndex(); + return new Rect( + index[0] * TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + index[1] * TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + TERRAIN_BLOCK_TILE_COMPLEXITY * terrain.info.tileSize, + ).contains(picked); + }) ?? null; + this.setSelectBlock(this._selectBlock === block ? null : block); + } + private _updateBlockSelectMaterial(block: TerrainBlock, material: Material | null) { + TerrainBrush.updateBrushDepthOffsetToMaterial(material); block.setBrushMaterial(material); + } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor.ts new file mode 100644 index 000000000..3f25b62b5 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-editor.ts @@ -0,0 +1,91 @@ +import { Camera, Terrain, Vec3 } from 'cc'; +import { Service } from '../../../core/decorator'; +import ControllerUtils from '../../utils/controller-utils'; +import { TerrainBrush } from './terrain-brush'; +import { TerrainEditorManage } from './terrain-editor-manage'; +import { TerrainEditorMode, eTerrainEditorMode } from './terrain-editor-mode'; +import { TerrainEditorPaint } from './terrain-editor-paint'; +import { TerrainEditorSculpt } from './terrain-editor-sculpt'; +import { TerrainEditorSelect } from './terrain-editor-select'; +import type TerrainGizmo from './gizmo-select'; + +const tempVec3_1 = new Vec3(); +const tempVec3_2 = new Vec3(); +const tempVec3_3 = new Vec3(); + +export class TerrainEditor { + private _terrain: Terrain | null = null; + private _modes: [TerrainEditorManage, TerrainEditorSculpt, TerrainEditorPaint, TerrainEditorSelect]; + private _currentMode: TerrainEditorMode | null = null; + private _cameraComp: Camera | null; + public isChanged = false; + private _gizmo: TerrainGizmo; + + constructor(camera: Camera | null, gizmo: TerrainGizmo) { + this._cameraComp = camera; + this._gizmo = gizmo; + this._modes = [ + new TerrainEditorManage(gizmo), new TerrainEditorSculpt(gizmo), + new TerrainEditorPaint(gizmo), new TerrainEditorSelect(gizmo), + ]; + this.setMode(eTerrainEditorMode.MANAGE); + } + public setEditTerrain(terrain: Terrain | null) { this._terrain = terrain; } + public getEditTerrain() { return this._terrain; } + public setMode(mode: eTerrainEditorMode) { + this._currentMode?.onDeactivate(); this._currentMode = this._modes[mode]; this._currentMode.onActivate(); this.clearBrush(); + } + public clearBrush() { + this._terrain?.getBlocks().forEach((block) => block.setBrushMaterial(null)); + this._currentMode?.onDeactivate(); + } + public getMode(mode: T) { return this._modes[mode] as [TerrainEditorManage, TerrainEditorSculpt, TerrainEditorPaint, TerrainEditorSelect][T]; } + public getCurrentMode() { return this._currentMode; } + public getCurrentModeType() { + const index = this._modes.indexOf(this._currentMode as any); + return index < 0 ? eTerrainEditorMode.SCULPT : index as eTerrainEditorMode; + } + public setCurrentLayer(layer: number) { this.getMode(eTerrainEditorMode.PAINT).setCurrentLayer(layer); } + public getCurrentLayer() { return this.getMode(eTerrainEditorMode.PAINT).getCurrentLayer(); } + public update(deltaTime: number, shiftDown: boolean) { + if (!this._currentMode || !this._terrain) return; + this._currentMode.onUpdate(this._terrain, deltaTime, shiftDown); + Service.Engine.repaintInEditMode(); + } + public onMouseDown(x: number, y: number) { + if (!this._terrain) return; + this.isChanged = false; + const sculpt = this.getMode(eTerrainEditorMode.SCULPT), paint = this.getMode(eTerrainEditorMode.PAINT), select = this.getMode(eTerrainEditorMode.SELECT); + if (this._currentMode === sculpt) { sculpt.onMouseDown(this._terrain); this.isChanged = true; } + else if (this._currentMode === paint) { paint.onMouseDown(this._terrain); this.isChanged = true; } + else if (this._currentMode === select && this._cameraComp) select.onMouseDown(this._terrain, this._cameraComp, x, y); + Service.Engine.repaintInEditMode(); + } + public onMouseUp() { + if (!this._terrain) return; + const sculpt = this.getMode(eTerrainEditorMode.SCULPT), paint = this.getMode(eTerrainEditorMode.PAINT); + if (this._currentMode === sculpt) sculpt.onMouseUp(); else if (this._currentMode === paint) paint.onMouseUp(); + this.isChanged = false; Service.Engine.repaintInEditMode(); + } + public onMouseMove(x: number, y: number) { + if (!this._terrain || !this._cameraComp) return; + const from = this._cameraComp.node.getWorldPosition(); + tempVec3_2.set(x, y, 0); this._cameraComp.screenToWorld(tempVec3_2, tempVec3_1); + Vec3.subtract(tempVec3_3, tempVec3_1, from).normalize(); + const hit = this._terrain.rayCheck(from, tempVec3_3, 0.35, true); + if (!hit) return; + const sculpt = this.getMode(eTerrainEditorMode.SCULPT), paint = this.getMode(eTerrainEditorMode.PAINT); + if (this._currentMode === sculpt) { sculpt.onUpdateBrushPosition(this._terrain, hit); this.isChanged = true; } + else if (this._currentMode === paint) { paint.onUpdateBrushPosition(this._terrain, hit); this.isChanged = true; } + Service.Engine.repaintInEditMode(); + } + public onHoverOut() { + if (this._currentMode !== this.getMode(eTerrainEditorMode.SELECT)) { this.clearBrush(); Service.Engine.repaintInEditMode(); } + } + public updateBlockDepthOffset() { + const node = this._gizmo.target?.node, camera = this._cameraComp; + if (!node || !camera) return; + TerrainBrush.updateBrushDepthOffset(ControllerUtils.getCameraDistanceFactor(node.position, camera.node)); + if (this._currentMode === this.getMode(eTerrainEditorMode.SELECT)) { this.getMode(eTerrainEditorMode.SELECT).forceUpdate(); Service.Engine.repaintInEditMode(); } + } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-operation.ts b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-operation.ts new file mode 100644 index 000000000..5c6dc8f9a --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/terrain/terrain-operation.ts @@ -0,0 +1,136 @@ +import { Rect, Terrain, TerrainBlock, Vec2, Vec3, Vec4 } from 'cc'; +import type { IUndoCommand, IUndoCommandMeta, IUndoRedoResult } from '../../../../../common'; + +function commandMeta(type: string, terrain: Terrain): IUndoCommandMeta { + return { + id: `terrain-${type}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + label: type === 'height' ? 'Terrain Sculpt' : 'Terrain Paint', + type: `terrain.${type}`, + scope: { editorType: 'scene', nodePath: terrain.node.uuid }, + timestamp: Date.now(), + }; +} + +export class TerrainHeightData { public x = 0; public y = 0; public value = 0; } + +/** A single height delta applied during one brush update. */ +export class TerrainHeightOperation { + protected _terrain: Terrain; + public data: TerrainHeightData[] = []; + constructor(terrain: Terrain) { this._terrain = terrain; } + set terrain(value: Terrain) { this._terrain = value; } + get terrain() { return this._terrain; } + public push(x: number, y: number, value: number) { + if (this.data.some((item) => item.x === x && item.y === y)) return; + this.data.push(Object.assign(new TerrainHeightData(), { x, y, value })); + } + public apply() { + const terrain = this._terrain; + if (!terrain || !this.data.length) return; + let xmin = this.data[0].x, xmax = xmin, ymin = this.data[0].y, ymax = ymin; + for (const item of this.data) { + terrain.setHeight(item.x, item.y, item.value); + xmin = Math.min(xmin, item.x); xmax = Math.max(xmax, item.x); + ymin = Math.min(ymin, item.y); ymax = Math.max(ymax, item.y); + } + xmin = Math.max(0, xmin - 1); ymin = Math.max(0, ymin - 1); + xmax = Math.min(terrain.info.vertexCount[0] - 1, xmax + 1); + ymax = Math.min(terrain.info.vertexCount[1] - 1, ymax + 1); + for (let y = ymin; y <= ymax; ++y) { + for (let x = xmin; x <= xmax; ++x) terrain._setNormal(x, y, terrain._calcNormal(x, y)); + } + const range = new Rect(xmin, ymin, xmax - xmin + 1, ymax - ymin + 1); + for (const block of terrain.getBlocks()) { + if (block.getRect().intersects(range)) { block._updateHeight(); block.update(); } + } + } +} + +export class TerrainHeightUndoRedo extends TerrainHeightOperation implements IUndoCommand { + public readonly meta: IUndoCommandMeta; + public redoOperations: TerrainHeightOperation[] = []; + constructor(terrain: Terrain) { super(terrain); this.meta = commandMeta('height', terrain); } + async undo(): Promise { this.apply(); return { success: true, commandId: this.meta.id, label: this.meta.label }; } + async redo(): Promise { + for (const operation of this.redoOperations) operation.apply(); + return { success: true, commandId: this.meta.id, label: this.meta.label }; + } +} + +export class TerrainWeightData { public x = 0; public y = 0; public value = new Vec4(); } + +export class TerrainWeightOperation { + protected _terrain: Terrain; + public data: TerrainWeightData[] = []; + constructor(terrain: Terrain) { this._terrain = terrain; } + set terrain(value: Terrain) { this._terrain = value; } + get terrain() { return this._terrain; } + public push(x: number, y: number, value: Vec4) { + if (this.data.some((item) => item.x === x && item.y === y)) return; + const item = new TerrainWeightData(); item.x = x; item.y = y; item.value.set(value); this.data.push(item); + } + public apply() { + const terrain = this._terrain; + if (!terrain) return; + const changed = new Set(); + for (const item of this.data) { + terrain.setWeight(item.x, item.y, item.value); + const block = terrain.getBlock( + Math.floor(item.x / terrain.info.weightMapSize), + Math.floor(item.y / terrain.info.weightMapSize), + ); + if (block) changed.add(block); + } + for (const block of changed) { block._updateWeightMap(); block.update(); } + } +} + +export class TerrainBlockLayerData { + public readonly block: TerrainBlock; + public readonly layers: number[]; + constructor(block: TerrainBlock, layers: number[]) { this.block = block; this.layers = [...layers]; } +} + +export class TerrainWeightUndoRedo extends TerrainWeightOperation implements IUndoCommand { + public readonly meta: IUndoCommandMeta; + public readonly undoBlockLayers: TerrainBlockLayerData[] = []; + public readonly redoBlockLayers: TerrainBlockLayerData[] = []; + public readonly redoOperations: TerrainWeightOperation[] = []; + constructor(terrain: Terrain) { super(terrain); this.meta = commandMeta('weight', terrain); } + private applyLayers(items: TerrainBlockLayerData[]) { + for (const item of items) item.layers.forEach((layer, index) => item.block.setLayer(index, layer)); + } + async undo(): Promise { + this.applyLayers(this.undoBlockLayers); this.apply(); + return { success: true, commandId: this.meta.id, label: this.meta.label }; + } + async redo(): Promise { + this.applyLayers(this.redoBlockLayers); for (const operation of this.redoOperations) operation.apply(); + return { success: true, commandId: this.meta.id, label: this.meta.label }; + } + public pushBlock(block: TerrainBlock, undoLayers: number[], redoLayers: number[]) { + const redo = this.redoBlockLayers.find((item) => item.block === block); + if (redo) redo.layers.splice(0, redo.layers.length, ...redoLayers); + else this.redoBlockLayers.push(new TerrainBlockLayerData(block, redoLayers)); + if (!this.undoBlockLayers.some((item) => item.block === block)) { + this.undoBlockLayers.push(new TerrainBlockLayerData(block, undoLayers)); + } + } +} + +/** Kept for API compatibility with the 3.x layer operation implementation. */ +export class TerrainLayerOperation { + protected _terrain: Terrain; + protected _layers: (any)[] = []; + constructor(terrain: Terrain) { this._terrain = terrain; } + set terrain(value: Terrain) { this._terrain = value; } + get terrain() { return this._terrain; } + setLayers() { this._layers = (this._terrain as any)._layerList?.slice?.() ?? []; } + apply() { this._layers.forEach((layer, index) => (this._terrain as any).setLayer(index, layer)); } +} + +export class TerrainLayerUndoRedo extends TerrainLayerOperation { + public redoOperations: TerrainLayerOperation[] = []; + undo() { this.apply(); } + redo() { this.redoOperations.forEach((operation) => operation.apply()); } +} diff --git a/src/core/scene/scene-process/service/gizmo/controller/terrain.ts b/src/core/scene/scene-process/service/gizmo/controller/terrain.ts new file mode 100644 index 000000000..408af666e --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/controller/terrain.ts @@ -0,0 +1,39 @@ +import { MeshRenderer, Node, Vec3 } from 'cc'; +import ControllerBase from './base'; +import ControllerShape from '../utils/controller-shape'; +import ControllerUtils from '../utils/controller-utils'; +import type { GizmoMouseEvent } from '../utils/defines'; +import { getModel, setNodeOpacity, updateBoundingBox, updatePositions } from '../utils/engine-utils'; + +/** Invisible quad used because TerrainBlock geometry is not a raycast target. */ +export default class TerrainController extends ControllerBase { + private _quadNode: Node | null = null; + private _quadMR: MeshRenderer | null = null; + private _size = 10; + + constructor(rootNode: Node, opts: any = {}) { + super(rootNode); this.initShape(opts); + } + initShape(opts: any) { + this.createShapeNode('TerrainController'); + const quad = ControllerUtils.quad(new Vec3(), this._size, this._size, new Vec3(0, 1, 0), undefined, opts); + quad.parent = this.shape; this._quadNode = quad; this._quadMR = getModel(quad); + setNodeOpacity(quad, 0); this.registerMouseEvents(quad, 'quad'); + } + onMouseDown(event: GizmoMouseEvent) { this.onControllerMouseDown?.(event); } + onMouseMove(event: GizmoMouseEvent) { this.onControllerMouseMove?.(event); } + onMouseUp(event: GizmoMouseEvent) { this.onControllerMouseUp?.(event); } + onHoverIn(_event: GizmoMouseEvent) {} + onHoverOut(event: GizmoMouseEvent<{ hoverInNodeMap: Map }>) { this.onControllerHoverOut?.(event); } + onShow() { + if (!this._eventsRegistered) { this.registerCameraMovedEvent(); this._eventsRegistered = true; } + } + onHide() { + if (this._eventsRegistered) { this.unregisterCameraMoveEvent(); this._eventsRegistered = false; } + } + updateWorldPosition(value: Vec3) { this._quadNode?.setWorldPosition(value); } + updateSize(width: number, height: number) { + const data = ControllerShape.calcQuadData(new Vec3(width / 2, 0, height / 2), width, height, new Vec3(0, 1, 0)); + if (this._quadMR) { updatePositions(this._quadMR, data.positions); updateBoundingBox(this._quadMR, data.minPos, data.maxPos); } + } +} diff --git a/src/core/scene/scene-process/service/gizmo/utils/defines.ts b/src/core/scene/scene-process/service/gizmo/utils/defines.ts index b4412aefe..b10344678 100644 --- a/src/core/scene/scene-process/service/gizmo/utils/defines.ts +++ b/src/core/scene/scene-process/service/gizmo/utils/defines.ts @@ -1,5 +1,12 @@ import { Vec3, Vec2, primitives, Node, Color, MeshRenderer, IVec3Like, Event as CCEvent } from 'cc'; +// Some CLI unit tests intentionally provide a minimal `cc` mock without Event. +// Keep the data-only mouse event usable in that environment while using the +// engine Event implementation whenever it is available. +const GizmoMouseEventBase = (CCEvent ?? class { + constructor(_type?: string, _bubbles?: boolean) {} +}) as typeof CCEvent; + export interface IMeshPrimitive { primitiveType?: number; // 图元类型 positions: Readonly[]; // 顶点坐标 @@ -118,7 +125,7 @@ export interface IHandleData { * 简化版 GizmoMouseEvent * 编辑器版本继承自 CCEvent,此处作为独立的纯数据类 */ -export class GizmoMouseEvent = {}> extends CCEvent { +export class GizmoMouseEvent = {}> extends GizmoMouseEventBase { ctrlKey = false; shiftKey = false; altKey = false; diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index 5aa4fe94a..19c52ddc2 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -3,6 +3,7 @@ export * from './editor'; export * from './node'; export * from './script'; export * from './asset'; +export * from './terrain'; import './effect'; export * from './component'; export * from './engine'; diff --git a/src/core/scene/scene-process/service/interfaces.ts b/src/core/scene/scene-process/service/interfaces.ts index 30ddcf09f..7556195ae 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -32,6 +32,8 @@ import { IPublicUIService, IUIService, IAnimationService, + IPublicTerrainService, + ITerrainService, } from '../../common'; /** @@ -54,6 +56,7 @@ export interface IPublicServiceManager { SceneView: IPublicSceneViewService, Preview: IPublicPreviewService, UI: IPublicUIService, + Terrain: IPublicTerrainService, } export interface IServiceManager { @@ -74,4 +77,5 @@ export interface IServiceManager { SceneView: ISceneViewService, Preview: IPreviewService, UI: IUIService, + Terrain: ITerrainService, } diff --git a/src/core/scene/scene-process/service/service-manager.ts b/src/core/scene/scene-process/service/service-manager.ts index 007085de4..d22898fc5 100644 --- a/src/core/scene/scene-process/service/service-manager.ts +++ b/src/core/scene/scene-process/service/service-manager.ts @@ -29,6 +29,9 @@ const MESSAGE_ONLY_EVENTS = [ 'camera:fov-changed', 'scene-view:visibility-changed', 'scene-view:light-changed', + 'terrain:changed', + 'terrain:sculpt', + 'terrain:block-update', ] as const; // 定义事件分组映射 diff --git a/src/core/scene/scene-process/service/terrain.ts b/src/core/scene/scene-process/service/terrain.ts new file mode 100644 index 000000000..ce13c445c --- /dev/null +++ b/src/core/scene/scene-process/service/terrain.ts @@ -0,0 +1,221 @@ +import { Component, Terrain, TerrainAsset } from 'cc'; +import { BaseService, register } from './core'; +import { ServiceEvents } from './core/global-events'; +import { getEditorNodeByUuid, getEditorNodeByPath } from './gizmo/utils/editor-node'; +import { loadAny } from './node/node-create'; +import { Rpc } from '../rpc'; +import type { ITerrainEvents, ITerrainService } from '../../common'; + +/** + * Terrain 资源生命周期管理。 + * + * 3.x 的 terrain manager 依赖 Editor.Dialog/Plugin;CLI 只保留其数据和资产接口, + * 将“是否询问用户”交给 pink。已有 .terrain 资源直接 saveAsset,未绑定资源时返回 2, + * 由 UI 决定 Save As 的目标 URL。 + */ +@register('Terrain') +export class TerrainService extends BaseService implements ITerrainService { + public readonly name = 'cc.Terrain' as const; + public readonly editedComponents: Terrain[] = []; + public readonly selectedComponents: Terrain[] = []; + + init() { + // SelectionService broadcasts paths, while the old manager received node UUIDs. + // Keep both entry points so pink can use either protocol. + ServiceEvents.on('selection:select', (path: string) => this.onSelectionSelect(path)); + ServiceEvents.on('selection:unselect', (path: string) => this.onSelectionUnselect(path)); + ServiceEvents.on('selection:clear', () => this.onSelectionClear()); + } + + private terrainOfNode(node: any): Terrain | null { + return node?.components?.find((component: Component) => component instanceof Terrain + || (component as any).__classname__ === 'cc.Terrain') as Terrain | null ?? null; + } + + private terrainOfUuid(uuid: string): Terrain | null { + return this.terrainOfNode(getEditorNodeByUuid(uuid)); + } + + private setManager(component: Terrain, value: any) { + (component as any).manager = value; + } + + private setDirty(component: Terrain, value: boolean) { + (component as any).isTerrainChange = value; + if (value) { + this.emit('terrain:changed', component); + } + } + + public get isTerrainChange(): boolean { + return this.editedComponents.some((component) => (component as any).isTerrainChange === true); + } + + public set isTerrainChange(value: boolean) { + for (const component of this.selectedComponents) { + this.setDirty(component, value); + } + } + + public select(nodeUuid: string): void { + const component = this.terrainOfUuid(nodeUuid); + if (!component) return; + this.setManager(component, this); + if (!this.selectedComponents.includes(component)) this.selectedComponents.push(component); + if (!this.editedComponents.includes(component)) this.editedComponents.push(component); + } + + public unselect(nodeUuid: string): void { + const component = this.terrainOfUuid(nodeUuid); + if (!component) return; + this.setManager(component, null); + const selectedIndex = this.selectedComponents.indexOf(component); + if (selectedIndex >= 0) this.selectedComponents.splice(selectedIndex, 1); + // Keep editedComponents until the node is removed, like the 3.x manager. + } + + public onSelectionSelect(path: string): void { + const node = getEditorNodeByPath(path); + if (node) this.select(node.uuid); + } + + public onSelectionUnselect(path: string): void { + const node = getEditorNodeByPath(path); + if (node) this.unselect(node.uuid); + } + + public onSelectionClear(): void { + for (const component of this.selectedComponents) this.setManager(component, null); + this.selectedComponents.length = 0; + } + + public onNodeRemoved(node: any): void { + const component = this.terrainOfNode(node); + if (!component) return; + this.removeComponent(component); + } + + public onComponentRemoved(component: Component): void { + if (component instanceof Terrain) this.removeComponent(component); + } + + private removeComponent(component: Terrain) { + this.setManager(component, null); + const selected = this.selectedComponents.indexOf(component); + if (selected >= 0) this.selectedComponents.splice(selected, 1); + const edited = this.editedComponents.indexOf(component); + if (edited >= 0) this.editedComponents.splice(edited, 1); + } + + public onSculpt(node: any): void { + this.emit('terrain:sculpt', node); + } + + public serialize(component: Terrain): Uint8Array { + const asset = component.exportAsset(); + // TerrainAsset's binary export is the canonical .terrain native payload. + return asset._exportNativeData(); + } + + public async saveAsset(isClose = false, component?: Terrain): Promise<0 | 1 | 2> { + const targets = component ? [component] : this.editedComponents; + let result: 0 | 1 | 2 = 1; + for (const terrain of targets) { + if (!(terrain as any).isTerrainChange) continue; + const uuid = terrain._asset?._uuid; + if (!uuid) { + result = 2; + continue; + } + try { + const saved = await Rpc.getInstance().request('assetManager', 'saveAsset', [ + uuid, + Buffer.from(this.serialize(terrain)), + ]); + if (!saved || saved.uuid !== uuid) { + result = 2; + continue; + } + this.setDirty(terrain, false); + result = 0; + } catch (error) { + console.error('[Terrain] saveAsset failed:', error); + result = 2; + } + } + return result; + } + + public async saveAssetDialog(file?: string, isClose = false): Promise<0 | 1 | 2> { + let result: 0 | 1 | 2 = 1; + for (const terrain of this.editedComponents) { + if (!(terrain as any).isTerrainChange) continue; + const uuid = terrain._asset?._uuid; + if (uuid) { + const code = await this.saveAsset(isClose, terrain); + if (code === 2) result = 2; + else if (code === 0) result = 0; + continue; + } + + // No UI is intentionally implemented here. pink can pass a db:// target + // through `file` to create the asset, or use its own Save As dialog. + if (!file) { + result = 2; + continue; + } + try { + const created = await Rpc.getInstance().request('assetManager', 'createAsset', [{ + target: file, + content: Buffer.from(this.serialize(terrain)), + overwrite: true, + }]); + if (created) { + (terrain as any)._asset = await loadAny(created.uuid ?? created); + this.setDirty(terrain, false); + result = 0; + } else { + result = 2; + } + } catch (error) { + console.error('[Terrain] create terrain asset failed:', error); + result = 2; + } + } + return result; + } + + public async close(): Promise<0 | 1 | 2> { + if (!this.isTerrainChange) return 1; + return this.saveAssetDialog(undefined, true); + } + + public async addAssetToComp(assetUuid: string): Promise { + for (const terrain of this.selectedComponents) { + if (assetUuid) { + try { + (terrain as any)._asset = await loadAny(assetUuid); + } catch { + (terrain as any)._asset = null; + } + } else if (!(terrain as any)._asset) { + (terrain as any)._asset = new TerrainAsset(); + this.setDirty(terrain, false); + } + ServiceEvents.emit('node:change', terrain.node, { type: 'component-changed' }); + } + } + + public onAssetDeleted(uuid: string): void { + for (const terrain of this.editedComponents) { + if (terrain._asset?._uuid === uuid) { + ServiceEvents.emit('node:change', terrain.node, { type: 'component-changed' }); + } + } + } + + public onEditorClosed(): void { + this.onSelectionClear(); + this.editedComponents.length = 0; + } +}