414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import * as THREE from 'three'
|
|
import type { BossInput } from './actionCombatTypes'
|
|
import type { SpellSlot } from './actionCombatCore'
|
|
|
|
export const ACTION_ARENA_SCALE = 48
|
|
export const ACTION_PLAYER_KEYS = new Set(['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'])
|
|
export const ACTION_RENDER_QUALITY = {
|
|
antialias: false,
|
|
maxPixelRatio: 1.1,
|
|
shadows: false,
|
|
}
|
|
export const CLAUDECRAFT_CAMERA_FOV = 60
|
|
const CLAUDECRAFT_CAMERA_PITCH = 0.32
|
|
const CLAUDECRAFT_CAMERA_DISTANCE = 12
|
|
const CLAUDECRAFT_CAMERA_EYE_HEIGHT = 2
|
|
const CLAUDECRAFT_CAMERA_DRAG_SENSITIVITY = 0.006
|
|
|
|
export type ActionWorldPoint = { x: number, z: number }
|
|
export type ActionCameraTarget = { x: number, y: number }
|
|
export type ActionArenaProjection = { height: number, width: number }
|
|
export type ActionWorldProjector = (x: number, y: number) => ActionWorldPoint
|
|
export type ActionCombatCameraController = {
|
|
bindDrag: (element: HTMLElement, options?: {
|
|
isPaused?: () => boolean,
|
|
onPointerDown?: (event: PointerEvent) => void,
|
|
}) => () => void
|
|
reset: (yaw?: number) => void
|
|
yaw: number
|
|
}
|
|
|
|
export type CombatActionQueue = {
|
|
reset: boolean
|
|
}
|
|
|
|
export function readCombatInput(
|
|
keys: Set<string>,
|
|
cameraYaw: number,
|
|
targetId: string | null,
|
|
castSpell: SpellSlot | null,
|
|
actions: CombatActionQueue,
|
|
options?: { fallbackAim?: { x: number, y: number } },
|
|
): BossInput {
|
|
const strafe = Number(keys.has('d') || keys.has('arrowright')) - Number(keys.has('a') || keys.has('arrowleft'))
|
|
const forwardInput = Number(keys.has('w') || keys.has('arrowup')) - Number(keys.has('s') || keys.has('arrowdown'))
|
|
const forwardX = Math.sin(cameraYaw)
|
|
const forwardY = Math.cos(cameraYaw)
|
|
const rightX = -Math.cos(cameraYaw)
|
|
const rightY = Math.sin(cameraYaw)
|
|
const xAxis = rightX * strafe + forwardX * forwardInput
|
|
const yAxis = rightY * strafe + forwardY * forwardInput
|
|
const length = Math.hypot(xAxis, yAxis)
|
|
const fallbackAim = options?.fallbackAim ?? { x: 0, y: -1 }
|
|
|
|
return {
|
|
xAxis,
|
|
yAxis,
|
|
reset: actions.reset,
|
|
targetDelta: 0,
|
|
targetId,
|
|
castSpell,
|
|
aimX: length > 0.01 ? xAxis / length : fallbackAim.x,
|
|
aimY: length > 0.01 ? yAxis / length : fallbackAim.y,
|
|
}
|
|
}
|
|
|
|
export function clearCombatActions(actions: CombatActionQueue) {
|
|
actions.reset = false
|
|
}
|
|
|
|
export function setupActionSceneEnvironment(scene: THREE.Scene, options?: { divider?: boolean, worldHeight?: number, worldWidth?: number }) {
|
|
scene.background = new THREE.Color(0x10151a)
|
|
scene.fog = new THREE.Fog(0x10151a, 14, 30)
|
|
const worldWidth = options?.worldWidth ?? 20
|
|
const worldHeight = options?.worldHeight ?? 11.25
|
|
|
|
const ambient = new THREE.HemisphereLight(0xf8f0d8, 0x1c2530, 1.7)
|
|
scene.add(ambient)
|
|
|
|
const sun = new THREE.DirectionalLight(0xfff1c2, 2.8)
|
|
sun.position.set(-6, 11, 8)
|
|
sun.castShadow = true
|
|
sun.shadow.camera.left = -13
|
|
sun.shadow.camera.right = 13
|
|
sun.shadow.camera.top = 9
|
|
sun.shadow.camera.bottom = -9
|
|
scene.add(sun)
|
|
|
|
const ground = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(worldWidth, worldHeight, 18, 10),
|
|
new THREE.MeshStandardMaterial({ color: 0x243126, roughness: 0.88 }),
|
|
)
|
|
ground.rotation.x = -Math.PI / 2
|
|
ground.receiveShadow = true
|
|
scene.add(ground)
|
|
|
|
const grid = new THREE.GridHelper(worldWidth, Math.max(20, Math.round(worldWidth)), 0x566044, 0x3b4436)
|
|
grid.position.y = 0.012
|
|
scene.add(grid)
|
|
|
|
addActionArenaBounds(scene, { divider: options?.divider, worldHeight, worldWidth })
|
|
}
|
|
|
|
export function addActionArenaBounds(scene: THREE.Scene, options?: { divider?: boolean, worldHeight?: number, worldWidth?: number }) {
|
|
const wallMaterial = new THREE.MeshStandardMaterial({ color: 0x32323a, roughness: 0.7 })
|
|
const worldWidth = options?.worldWidth ?? 20
|
|
const worldHeight = options?.worldHeight ?? 11.25
|
|
const halfW = worldWidth / 2
|
|
const halfH = worldHeight / 2
|
|
for (const wall of [
|
|
{ x: 0, z: -halfH - 0.15, sx: worldWidth + 0.4, sz: 0.28 },
|
|
{ x: 0, z: halfH + 0.15, sx: worldWidth + 0.4, sz: 0.28 },
|
|
{ x: -halfW - 0.15, z: 0, sx: 0.28, sz: worldHeight + 0.3 },
|
|
{ x: halfW + 0.15, z: 0, sx: 0.28, sz: worldHeight + 0.3 },
|
|
]) {
|
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(wall.sx, 0.62, wall.sz), wallMaterial)
|
|
mesh.position.set(wall.x, 0.31, wall.z)
|
|
mesh.castShadow = true
|
|
mesh.receiveShadow = true
|
|
scene.add(mesh)
|
|
}
|
|
|
|
if (!options?.divider) return
|
|
const dividerHeight = 3.45
|
|
const divider = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.22, dividerHeight, worldHeight - 0.85),
|
|
new THREE.MeshStandardMaterial({ color: 0x5d6470, roughness: 0.72, transparent: true, opacity: 0.48 }),
|
|
)
|
|
divider.position.set(0, dividerHeight / 2, 0)
|
|
divider.castShadow = true
|
|
divider.receiveShadow = true
|
|
scene.add(divider)
|
|
}
|
|
|
|
export function createActionCombatCamera(options?: { aspect?: number, far?: number, near?: number }) {
|
|
return new THREE.PerspectiveCamera(
|
|
CLAUDECRAFT_CAMERA_FOV,
|
|
options?.aspect ?? 16 / 9,
|
|
options?.near ?? 0.1,
|
|
options?.far ?? 120,
|
|
)
|
|
}
|
|
|
|
export function createActionCombatCameraController(initialYaw = Math.PI): ActionCombatCameraController {
|
|
let dragX: number | null = null
|
|
const controller: ActionCombatCameraController = {
|
|
yaw: initialYaw,
|
|
reset: (yaw = initialYaw) => {
|
|
controller.yaw = yaw
|
|
dragX = null
|
|
},
|
|
bindDrag: (element, options) => {
|
|
const isPaused = options?.isPaused ?? (() => false)
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (isPaused()) return
|
|
element.setPointerCapture(event.pointerId)
|
|
dragX = event.clientX
|
|
options?.onPointerDown?.(event)
|
|
}
|
|
const handlePointerMove = (event: PointerEvent) => {
|
|
if (isPaused() || dragX === null) return
|
|
controller.yaw -= (event.clientX - dragX) * CLAUDECRAFT_CAMERA_DRAG_SENSITIVITY
|
|
dragX = event.clientX
|
|
}
|
|
const handlePointerUp = (event: PointerEvent) => {
|
|
if (isPaused()) return
|
|
if (element.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId)
|
|
dragX = null
|
|
}
|
|
|
|
element.addEventListener('pointerdown', handlePointerDown)
|
|
element.addEventListener('pointermove', handlePointerMove)
|
|
element.addEventListener('pointerup', handlePointerUp)
|
|
element.addEventListener('pointercancel', handlePointerUp)
|
|
return () => {
|
|
element.removeEventListener('pointerdown', handlePointerDown)
|
|
element.removeEventListener('pointermove', handlePointerMove)
|
|
element.removeEventListener('pointerup', handlePointerUp)
|
|
element.removeEventListener('pointercancel', handlePointerUp)
|
|
}
|
|
},
|
|
}
|
|
return controller
|
|
}
|
|
|
|
export function resizeActionCombatCamera(camera: THREE.PerspectiveCamera, width: number, height: number) {
|
|
camera.aspect = Math.max(1, width) / Math.max(1, height)
|
|
camera.updateProjectionMatrix()
|
|
}
|
|
|
|
export function updateActionCombatCamera(
|
|
camera: THREE.PerspectiveCamera,
|
|
yaw: number,
|
|
target: ActionCameraTarget,
|
|
project: ActionWorldProjector = toActionWorld,
|
|
) {
|
|
const point = project(target.x, target.y)
|
|
applyClaudeCraftCamera(camera, yaw, point)
|
|
}
|
|
|
|
export function updateFollowCamera(camera: THREE.PerspectiveCamera, yaw: number, target: ActionCameraTarget) {
|
|
updateActionCombatCamera(camera, yaw, target)
|
|
}
|
|
|
|
export function applyClaudeCraftCamera(camera: THREE.PerspectiveCamera, yaw: number, point: ActionWorldPoint) {
|
|
const eyeY = CLAUDECRAFT_CAMERA_EYE_HEIGHT
|
|
camera.position.x = point.x - Math.sin(yaw) * Math.cos(CLAUDECRAFT_CAMERA_PITCH) * CLAUDECRAFT_CAMERA_DISTANCE
|
|
camera.position.y = eyeY + Math.sin(CLAUDECRAFT_CAMERA_PITCH) * CLAUDECRAFT_CAMERA_DISTANCE
|
|
camera.position.z = point.z - Math.cos(yaw) * Math.cos(CLAUDECRAFT_CAMERA_PITCH) * CLAUDECRAFT_CAMERA_DISTANCE
|
|
if (camera.fov !== CLAUDECRAFT_CAMERA_FOV) {
|
|
camera.fov = CLAUDECRAFT_CAMERA_FOV
|
|
camera.updateProjectionMatrix()
|
|
}
|
|
camera.lookAt(point.x, eyeY, point.z)
|
|
}
|
|
|
|
export function toActionWorld(x: number, y: number, arena: ActionArenaProjection = { height: 540, width: 960 }) {
|
|
return {
|
|
x: (x - arena.width / 2) / ACTION_ARENA_SCALE,
|
|
z: (y - arena.height / 2) / ACTION_ARENA_SCALE,
|
|
}
|
|
}
|
|
|
|
export function getLineTelegraph(scene: THREE.Scene, map: Map<string, THREE.Mesh>, id: string) {
|
|
let mesh = map.get(id)
|
|
if (!mesh) {
|
|
mesh = new THREE.Mesh(
|
|
new THREE.BufferGeometry(),
|
|
new THREE.MeshBasicMaterial({ color: 0xff5d43, transparent: true, opacity: 0.28, side: THREE.DoubleSide }),
|
|
)
|
|
scene.add(mesh)
|
|
map.set(id, mesh)
|
|
}
|
|
return mesh
|
|
}
|
|
|
|
export function syncLineTelegraphGeometry(
|
|
mesh: THREE.Mesh,
|
|
startPoint: { x: number; y: number },
|
|
endPoint: { x: number; y: number },
|
|
width: number,
|
|
project: (x: number, y: number) => { x: number, z: number } = toActionWorld,
|
|
) {
|
|
const start = project(startPoint.x, startPoint.y)
|
|
const end = project(endPoint.x, endPoint.y)
|
|
const dx = end.x - start.x
|
|
const dz = end.z - start.z
|
|
const length = Math.max(0.001, Math.hypot(dx, dz))
|
|
const normalX = -dz / length
|
|
const normalZ = dx / length
|
|
const halfWidth = width / ACTION_ARENA_SCALE / 2
|
|
const y = 0.035
|
|
const positions = new Float32Array([
|
|
start.x + normalX * halfWidth, y, start.z + normalZ * halfWidth,
|
|
start.x - normalX * halfWidth, y, start.z - normalZ * halfWidth,
|
|
end.x + normalX * halfWidth, y, end.z + normalZ * halfWidth,
|
|
end.x - normalX * halfWidth, y, end.z - normalZ * halfWidth,
|
|
])
|
|
const geometry = new THREE.BufferGeometry()
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
|
geometry.setIndex([0, 2, 1, 2, 3, 1])
|
|
geometry.computeVertexNormals()
|
|
mesh.geometry.dispose()
|
|
mesh.geometry = geometry
|
|
}
|
|
|
|
export function getCircleTelegraph(scene: THREE.Scene, map: Map<string, THREE.Mesh>, id: string) {
|
|
let mesh = map.get(id)
|
|
if (!mesh) {
|
|
mesh = new THREE.Mesh(
|
|
new THREE.CircleGeometry(1, 42),
|
|
new THREE.MeshBasicMaterial({ color: 0xffd36d, transparent: true, opacity: 0.24, side: THREE.DoubleSide }),
|
|
)
|
|
mesh.rotation.x = -Math.PI / 2
|
|
scene.add(mesh)
|
|
map.set(id, mesh)
|
|
}
|
|
return mesh
|
|
}
|
|
|
|
export function getArcTelegraph(scene: THREE.Scene, map: Map<string, THREE.Mesh>, id: string) {
|
|
let mesh = map.get(id)
|
|
if (!mesh) {
|
|
mesh = new THREE.Mesh(
|
|
new THREE.BufferGeometry(),
|
|
new THREE.MeshBasicMaterial({ color: 0xff8a43, transparent: true, opacity: 0.3, side: THREE.DoubleSide }),
|
|
)
|
|
scene.add(mesh)
|
|
map.set(id, mesh)
|
|
}
|
|
return mesh
|
|
}
|
|
|
|
export function syncArcTelegraphGeometry(
|
|
mesh: THREE.Mesh,
|
|
centerPoint: { x: number; y: number },
|
|
radius: number,
|
|
startAngle: number,
|
|
endAngle: number,
|
|
width: number,
|
|
project: (x: number, y: number) => { x: number, z: number } = toActionWorld,
|
|
) {
|
|
const center = project(centerPoint.x, centerPoint.y)
|
|
const innerRadius = Math.max(0, radius - width / 2) / ACTION_ARENA_SCALE
|
|
const outerRadius = Math.max(width, radius + width / 2) / ACTION_ARENA_SCALE
|
|
const segments = 32
|
|
const positions: number[] = []
|
|
const indices: number[] = []
|
|
const y = 0.045
|
|
for (let i = 0; i <= segments; i += 1) {
|
|
const t = i / segments
|
|
const angle = startAngle + (endAngle - startAngle) * t
|
|
positions.push(
|
|
center.x + Math.cos(angle) * innerRadius, y, center.z + Math.sin(angle) * innerRadius,
|
|
center.x + Math.cos(angle) * outerRadius, y, center.z + Math.sin(angle) * outerRadius,
|
|
)
|
|
if (i < segments) {
|
|
const base = i * 2
|
|
indices.push(base, base + 1, base + 2, base + 1, base + 3, base + 2)
|
|
}
|
|
}
|
|
const geometry = new THREE.BufferGeometry()
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3))
|
|
geometry.setIndex(indices)
|
|
geometry.computeVertexNormals()
|
|
mesh.geometry.dispose()
|
|
mesh.geometry = geometry
|
|
}
|
|
|
|
export function createHealBurstEffect(options?: { simple?: boolean, color?: number }) {
|
|
const color = options?.color ?? 0x7dff9d
|
|
const group = new THREE.Group()
|
|
const ring = new THREE.Mesh(
|
|
new THREE.TorusGeometry(options?.simple ? 0.34 : 0.38, options?.simple ? 0.026 : 0.028, 8, options?.simple ? 32 : 36),
|
|
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }),
|
|
)
|
|
ring.rotation.x = Math.PI / 2
|
|
group.add(ring)
|
|
|
|
if (options?.simple) return group
|
|
|
|
const pillar = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.18, 0.34, 0.9, 18, 1, true),
|
|
new THREE.MeshBasicMaterial({ color: 0xb8ffd0, transparent: true, opacity: 0.34, side: THREE.DoubleSide }),
|
|
)
|
|
pillar.position.y = 0.45
|
|
group.add(pillar)
|
|
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const mote = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.045, 8, 6),
|
|
new THREE.MeshBasicMaterial({ color: 0xf4fff2, transparent: true, opacity: 0.9 }),
|
|
)
|
|
const angle = (Math.PI * 2 * i) / 5
|
|
mote.position.set(Math.cos(angle) * 0.34, 0.25 + i * 0.12, Math.sin(angle) * 0.34)
|
|
group.add(mote)
|
|
}
|
|
|
|
return group
|
|
}
|
|
|
|
export function getOrCreateStunEffect(scene: THREE.Scene, effects: Map<string, THREE.Group>, id: string) {
|
|
let group = effects.get(id)
|
|
if (!group) {
|
|
group = new THREE.Group()
|
|
const starMaterial = new THREE.MeshBasicMaterial({ color: 0xffd36d, transparent: true, opacity: 0.95 })
|
|
for (let i = 0; i < 3; i += 1) {
|
|
const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.09), starMaterial.clone())
|
|
const angle = (Math.PI * 2 * i) / 3
|
|
star.position.set(Math.cos(angle) * 0.28, Math.sin(i * 1.7) * 0.05, Math.sin(angle) * 0.28)
|
|
star.rotation.set(i * 0.7, i * 0.4, i * 0.9)
|
|
group.add(star)
|
|
}
|
|
const ring = new THREE.Mesh(
|
|
new THREE.TorusGeometry(0.28, 0.012, 6, 24),
|
|
new THREE.MeshBasicMaterial({ color: 0xfff0a8, transparent: true, opacity: 0.62 }),
|
|
)
|
|
ring.rotation.x = Math.PI / 2
|
|
group.add(ring)
|
|
scene.add(group)
|
|
effects.set(id, group)
|
|
}
|
|
return group
|
|
}
|
|
|
|
export function setObjectOpacity(group: THREE.Object3D, opacity: number) {
|
|
group.traverse((child) => {
|
|
if (!(child instanceof THREE.Mesh)) return
|
|
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
|
for (const material of materials) {
|
|
material.transparent = true
|
|
material.opacity = opacity
|
|
}
|
|
})
|
|
}
|
|
|
|
export function pruneObjectMap<T extends THREE.Object3D>(map: Map<string, T>, active: Set<string>) {
|
|
for (const [id, object] of map) {
|
|
if (active.has(id)) continue
|
|
disposeObject3d(object)
|
|
map.delete(id)
|
|
}
|
|
}
|
|
|
|
export function disposeObject3d(object: THREE.Object3D) {
|
|
object.removeFromParent()
|
|
object.traverse((child) => {
|
|
if (!(child instanceof THREE.Mesh)) return
|
|
child.geometry.dispose()
|
|
const material = child.material
|
|
if (Array.isArray(material)) material.forEach((item) => item.dispose())
|
|
else material.dispose()
|
|
})
|
|
}
|