Files
editor/apps/editor/components/tools/wall/wall-tool.tsx
T
2026-03-02 10:10:58 +09:00

216 lines
6.9 KiB
TypeScript

import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Mesh, type Group, Shape, ShapeGeometry, Vector3 } from 'three'
import { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const WALL_HEIGHT = 2.5
const WALL_THICKNESS = 0.15
/**
* Snap point to 45° angle increments relative to start point
* Also snaps end point to 0.5 grid
*/
const snapTo45Degrees = (start: Vector3, cursor: Vector3): Vector3 => {
const dx = cursor.x - start.x
const dz = cursor.z - start.z
// Calculate angle in radians
const angle = Math.atan2(dz, dx)
// Round to nearest 45° (π/4 radians)
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
// Calculate distance from start to cursor
const distance = Math.sqrt(dx * dx + dz * dz)
// Project end point along snapped angle
let snappedX = start.x + Math.cos(snappedAngle) * distance
let snappedZ = start.z + Math.sin(snappedAngle) * distance
// Snap to 0.5 grid
snappedX = Math.round(snappedX * 2) / 2
snappedZ = Math.round(snappedZ * 2) / 2
return new Vector3(snappedX, cursor.y, snappedZ)
}
/**
* Update wall preview mesh geometry to create a vertical plane between two points
*/
const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
// Calculate direction and perpendicular for wall thickness
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
mesh.visible = false
return
}
mesh.visible = true
direction.normalize()
// Perpendicular vector for thickness
const perpendicular = new Vector3(-direction.z, 0, direction.x).multiplyScalar(WALL_THICKNESS / 2)
// Create wall shape (vertical rectangle in XY plane)
const shape = new Shape()
shape.moveTo(0, 0)
shape.lineTo(length, 0)
shape.lineTo(length, WALL_HEIGHT)
shape.lineTo(0, WALL_HEIGHT)
shape.closePath()
// Create geometry
const geometry = new ShapeGeometry(shape)
// Calculate rotation angle
// Negate the angle to fix the opposite direction issue
const angle = -Math.atan2(direction.z, direction.x)
// Position at start point and rotate
mesh.position.set(start.x, start.y, start.z)
mesh.rotation.y = angle
// Dispose old geometry and assign new one
if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry
}
const commitWallDrawing = (start: [number, number], end: [number, number]) => {
const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState()
if (!currentLevelId) return
const wallCount = Object.values(nodes).filter((n) => n.type === 'wall').length
const name = `Wall ${wallCount + 1}`
const wall = WallNode.parse({ name, start, end })
createNode(wall, currentLevelId)
sfxEmitter.emit('sfx:structure-build')
}
export const WallTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
useEffect(() => {
let gridPosition: [number, number] = [0, 0]
let previousWallEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current || !wallPreviewRef.current) return
gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2]
const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1])
if (buildingState.current === 1) {
// Snap to 45° angles only if shift is not pressed
const snapped = shiftPressed.current
? cursorPosition
: snapTo45Degrees(startingPoint.current, cursorPosition)
endingPoint.current.copy(snapped)
// Position the cursor at the end of the wall being drawn
cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
// Play snap sound only when the actual wall end position changes
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
if (previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousWallEnd = currentWallEnd
// Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
} else {
// Not drawing a wall, just follow the grid position
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
}
}
const onGridClick = (event: GridEvent) => {
if (buildingState.current === 0) {
startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1])
buildingState.current = 1
wallPreviewRef.current.visible = true
} else if (buildingState.current === 1) {
const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
commitWallDrawing(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
)
wallPreviewRef.current.visible = false
buildingState.current = 0
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = false
}
}
const onCancel = () => {
if (buildingState.current === 1) {
buildingState.current = 0
wallPreviewRef.current.visible = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [])
return (
<group>
{/* Cursor indicator */}
<CursorSphere ref={cursorRef} />
{/* Wall preview */}
<mesh ref={wallPreviewRef} visible={false} renderOrder={1} layers={EDITOR_LAYER}>
<shapeGeometry />
<meshBasicMaterial
color="#818cf8"
transparent
opacity={0.5}
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh>
</group>
)
}