/
RaskolnickOFF
/
3D
Обзор
Документация
Войти
/
RaskolnickOFF
/
3D
Код
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/source/systems/render/TerrainEditorSystem.js
424 строки
15 KB
RaskolnickOFF
Structure update
06 июн 2026, 03:18
06 июн 2026, 03:18
9bc1c25
Код
Авторство
О чём код?
// js/source/systems/render/TerrainEditorSystem.js // Tile-based terraform system with player-distance check for all actions import * as THREE from 'three'; import { Components } from '../../core/Components.js'; import { GameEvents } from '../../core/GameEvents.js'; export class TerrainEditorSystem { constructor(renderCtx, inputSystem, heightmapData, groundApi, gridOverlay) { this.scene = renderCtx.scene; this.camera = renderCtx.camera; this.renderer = renderCtx.renderer; this.inputSystem = inputSystem; this.eventBus = renderCtx.eventBus; this.settings = renderCtx.settings; this.heightmapData = heightmapData; this.groundApi = groundApi; this.gridOverlay = gridOverlay; this.playerEntity = null; this.worldSize = this.settings.terrain.size; this.halfWorld = this.worldSize / 2; this.maxSlope = 10.0; this.heightStep = 0.1; this.actionRadius = 2; // тайлов от игрока this._snappedX = 0; this._snappedZ = 0; this._lastSnappedX = undefined; this._lastSnappedZ = undefined; this._wasZDown = false; this._wasXDown = false; this._wasCDown = false; this._raycaster = new THREE.Raycaster(); // GROUND this._groundGroup = groundApi.group; this.scene.add(this._groundGroup); // GRID this.scene.add(this.gridOverlay.mesh); // RAYCAST PLANE this._raycastPlane = new THREE.Mesh( new THREE.PlaneGeometry(this.worldSize, this.worldSize), new THREE.MeshBasicMaterial({ visible: false }) ); this._raycastPlane.rotation.x = -Math.PI / 2; this._raycastPlane.name = 'EditorRaycastPlane'; this.scene.add(this._raycastPlane); // MARKER const markerSize = 0.35; const markerCanvas = document.createElement('canvas'); markerCanvas.width = 64; markerCanvas.height = 64; const mctx = markerCanvas.getContext('2d'); const gradient = mctx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, 'rgba(255,255,0,1)'); gradient.addColorStop(0.3, 'rgba(255,255,0,0.9)'); gradient.addColorStop(0.6, 'rgba(255,200,0,0.5)'); gradient.addColorStop(1, 'rgba(255,150,0,0)'); mctx.fillStyle = gradient; mctx.fillRect(0, 0, 64, 64); const markerTexture = new THREE.CanvasTexture(markerCanvas); const markerGeo = new THREE.PlaneGeometry(markerSize, markerSize); const markerMat = new THREE.MeshBasicMaterial({ map: markerTexture, transparent: true, depthTest: false, depthWrite: false, side: THREE.DoubleSide }); this._marker = new THREE.Mesh(markerGeo, markerMat); this._marker.rotation.x = -Math.PI / 2; this._marker.position.y = 0.06; this._marker.renderOrder = 999; this._marker.visible = false; this._marker.name = 'EditorMarker'; this.scene.add(this._marker); // FLASH this._originalMarkerMap = null; this._redMarkerTexture = null; this._flashTimeout = null; // EVENTS this.eventBus.on(GameEvents.EDIT_MODE_TOGGLED, ({ editMode }) => { this.gridOverlay.setVisible(editMode); this._marker.visible = editMode; if (this.playerEntity) { if (editMode) { this.playerEntity.add(Components.INVULNERABLE, true); } else { this.playerEntity.remove(Components.INVULNERABLE); } } }); this.eventBus.on(GameEvents.WORLD_LOADED, () => this._onWorldLoaded()); } // ========================================================================= // PLAYER // ========================================================================= setPlayerEntity(entity) { this.playerEntity = entity; } // ========================================================================= // UPDATE // ========================================================================= update() { this.groundApi.renderUpdate(this.renderer); if (!this.inputSystem.editMode) return; this._updateMarker(); this._handleClick(); } // ========================================================================= // MARKER // ========================================================================= _updateMarker() { this._raycaster.setFromCamera( { x: this.inputSystem.mouseX, y: this.inputSystem.mouseY }, this.camera ); const hit = this._intersectHeightfield(this._raycaster); if (!hit) return; this._snappedX = Math.floor(hit.x + this.halfWorld); this._snappedZ = Math.floor(hit.z + this.halfWorld); this._lastSnappedX = this._snappedX; this._lastSnappedZ = this._snappedZ; const worldX = this._snappedX + 0.5 - this.halfWorld; const worldZ = this._snappedZ + 0.5 - this.halfWorld; const h = this.heightmapData.getHeightAt(worldX, worldZ); this._marker.position.set(worldX, h + 0.06, worldZ); this._marker.visible = true; } // ========================================================================= // DISTANCE CHECK (shared by all actions) // ========================================================================= _isTileInRange(tileX, tileZ) { if (!this.playerEntity) return true; // нет игрока — разрешаем const transform = this.playerEntity.get(Components.TRANSFORM); if (!transform) return true; const playerTileX = Math.floor(transform.x + this.halfWorld); const playerTileZ = Math.floor(transform.z + this.halfWorld); const dx = Math.abs(playerTileX - tileX); const dz = Math.abs(playerTileZ - tileZ); return dx <= this.actionRadius && dz <= this.actionRadius; } _getPlayerTile() { if (!this.playerEntity) return null; const transform = this.playerEntity.get(Components.TRANSFORM); if (!transform) return null; return { x: Math.floor(transform.x + this.halfWorld), z: Math.floor(transform.z + this.halfWorld) }; } _getPlayerTileHeight() { const playerTile = this._getPlayerTile(); if (!playerTile) return 0; const verts = this._getTileVertices(playerTile.x, playerTile.z); let sum = 0; for (const [vx, vz] of verts) { sum += this.heightmapData.getVertex(vx, vz); } return sum / verts.length; } // ========================================================================= // INPUT // ========================================================================= _handleClick() { const raise = this.inputSystem.isPressed('z'); const lower = this.inputSystem.isPressed('x'); const flatten = this.inputSystem.isPressed('c'); if (raise && !this._wasZDown) { this._wasZDown = true; this._terraformTile(this._lastSnappedX, this._lastSnappedZ, this.heightStep); } if (!raise) this._wasZDown = false; if (lower && !this._wasXDown) { this._wasXDown = true; this._terraformTile(this._lastSnappedX, this._lastSnappedZ, -this.heightStep); } if (!lower) this._wasXDown = false; if (flatten && !this._wasCDown) { this._wasCDown = true; this._flattenTile(this._lastSnappedX, this._lastSnappedZ); } if (!flatten) this._wasCDown = false; } // ========================================================================= // TILE TERRAFORM (Z / X) // ========================================================================= _terraformTile(tileX, tileZ, delta) { if (tileX === undefined || tileZ === undefined) return; if (!this._isTileInRange(tileX, tileZ)) { this._flashMarker(0xff0000, 1000); return; } const verts = this._getTileVertices(tileX, tileZ); for (const [vx, vz] of verts) { const h = this.heightmapData.getVertex(vx, vz); this.heightmapData.setVertex(vx, vz, h + delta); } this._enforceLocalSlope(verts); this._updateMarkersForVerts(verts); this._commitTerrain(); } // ========================================================================= // FLATTEN (C) // ========================================================================= _flattenTile(tileX, tileZ) { if (tileX === undefined || tileZ === undefined) return; if (!this._isTileInRange(tileX, tileZ)) { this._flashMarker(0xff0000, 1000); return; } const targetHeight = this._getPlayerTileHeight(); const verts = this._getTileVertices(tileX, tileZ); for (const [vx, vz] of verts) { this.heightmapData.setVertex(vx, vz, targetHeight); } this._enforceLocalSlope(verts); this._updateMarkersForVerts(verts); this._commitTerrain(); } // ========================================================================= // MARKER UPDATE AFTER TERRAFORM // ========================================================================= _updateMarkersForVerts(verts) { const affectedTiles = new Set(); const ratio = this.heightmapData.segments / this.worldSize; for (const [vx, vz] of verts) { const tx = Math.floor(vx / ratio); const tz = Math.floor(vz / ratio); if (tx >= 0 && tz >= 0 && tx < 64 && tz < 64) { affectedTiles.add(JSON.stringify({ tx, tz })); } } if (affectedTiles.size > 0) { const tiles = [...affectedTiles].map(s => JSON.parse(s)); this.gridOverlay.updateMarkersAt(tiles); } } // ========================================================================= // SLOPE CONSTRAINTS // ========================================================================= _enforceLocalSlope(vertices) { const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]]; for (const [vx, vz] of vertices) { const h = this.heightmapData.getVertex(vx, vz); for (const [dx, dz] of dirs) { const nx = vx + dx; const nz = vz + dz; if (nx < 0 || nz < 0 || nx > this.heightmapData.segments || nz > this.heightmapData.segments) continue; const nh = this.heightmapData.getVertex(nx, nz); const delta = h - nh; if (Math.abs(delta) > this.maxSlope) { const corrected = h - Math.sign(delta) * this.maxSlope; this.heightmapData.setVertex(nx, nz, corrected); } } } } // ========================================================================= // TILE -> VERTICES // ========================================================================= _getTileVertices(tileX, tileZ) { const ratio = this.heightmapData.segments / this.worldSize; const vx = Math.floor(tileX * ratio); const vz = Math.floor(tileZ * ratio); return [[vx, vz], [vx + 1, vz], [vx, vz + 1], [vx + 1, vz + 1]]; } // ========================================================================= // GPU UPDATE // ========================================================================= _commitTerrain() { this.groundApi.refreshGeometry(); this.gridOverlay.refresh(); } // ========================================================================= // HEIGHTFIELD INTERSECTION // ========================================================================= _intersectHeightfield(raycaster) { const ray = raycaster.ray; const maxDist = 200; const step = 0.5; let prevDelta = ray.origin.y - this.heightmapData.getHeightAt(ray.origin.x, ray.origin.z); for (let t = step; t < maxDist; t += step) { const point = ray.at(t, new THREE.Vector3()); const terrainHeight = this.heightmapData.getHeightAt(point.x, point.z); const delta = point.y - terrainHeight; if (delta <= 0 && prevDelta > 0) { return this._binarySearchIntersection(ray, t - step, t); } prevDelta = delta; } return null; } _binarySearchIntersection(ray, tMin, tMax, iterations = 10) { const point = new THREE.Vector3(); for (let i = 0; i < iterations; i++) { const mid = (tMin + tMax) * 0.5; ray.at(mid, point); const terrainHeight = this.heightmapData.getHeightAt(point.x, point.z); if (point.y - terrainHeight > 0) { tMin = mid; } else { tMax = mid; } } return ray.at((tMin + tMax) * 0.5, new THREE.Vector3()); } // ========================================================================= // FLASH MARKER // ========================================================================= _flashMarker(colorHex, durationMs) { if (!this._marker) return; if (!this._originalMarkerMap) { this._originalMarkerMap = this._marker.material.map; } if (!this._redMarkerTexture) { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d'); const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, 'rgba(255,0,0,1)'); gradient.addColorStop(0.3, 'rgba(255,0,0,0.9)'); gradient.addColorStop(0.6, 'rgba(255,0,0,0.5)'); gradient.addColorStop(1, 'rgba(255,0,0,0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 64, 64); this._redMarkerTexture = new THREE.CanvasTexture(canvas); } this._marker.material.map = this._redMarkerTexture; this._marker.material.needsUpdate = true; clearTimeout(this._flashTimeout); this._flashTimeout = setTimeout(() => { if (this._originalMarkerMap) { this._marker.material.map = this._originalMarkerMap; this._marker.material.needsUpdate = true; } }, durationMs); } // ========================================================================= // WORLD LOADED // ========================================================================= _onWorldLoaded() { if (!this.scene.children.includes(this.gridOverlay.mesh)) { this.scene.add(this.gridOverlay.mesh); } if (!this.scene.children.includes(this._raycastPlane)) { this.scene.add(this._raycastPlane); } if (!this.scene.children.includes(this._marker)) { this.scene.add(this._marker); } if (this.inputSystem.editMode) { this.gridOverlay.setVisible(true); this._marker.visible = true; } } }