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, 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, 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, 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, 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, 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(map: Map, active: Set) { 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() }) }