Spaces:
Sleeping
Sleeping
| import * as THREE from "three"; | |
| import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; | |
| import type { BoardShape } from "../../../inc/trigo"; | |
| // Color constants | |
| const COLORS = { | |
| // Scene colors | |
| SCENE_BACKGROUND: 0x505055, | |
| SCENE_CLEAR: 0x505055, | |
| // Chess frame colors (three-tiered system) | |
| FRAME_CREST: 0xff4d4d, // Red-tinted for edges/corners | |
| FRAME_SURFACE: 0xe6b380, // Orange/yellow-tinted for face edges | |
| FRAME_INTERIOR: 0x999999, // Gray for interior lines | |
| // intersection point colors | |
| POINT_DEFAULT: 0x4a90e2, | |
| POINT_HOVERED: 0x00ff00, | |
| POINT_HOVERED_DISABLED: 0xff0000, | |
| POINT_AXIS_ALIGNED: 0xffaa00, | |
| POINT_AIR_PATCH: 0x80e680, // Semi-transparent green for liberties in inspect mode | |
| // Stone colors | |
| STONE_BLACK: 0x070707, | |
| STONE_WHITE: 0xf0f0f0, | |
| // Stone specular highlights | |
| STONE_BLACK_SPECULAR: 0x445577, | |
| STONE_WHITE_SPECULAR: 0xeeeedd, | |
| // Lighting colors | |
| AMBIENT_LIGHT: 0xffffff, | |
| DIRECTIONAL_LIGHT: 0xffffff, | |
| HEMISPHERE_LIGHT_SKY: 0xeefaff, | |
| HEMISPHERE_LIGHT_GROUND: 0x20201a | |
| } as const; | |
| // Opacity constants | |
| const OPACITY = { | |
| // Chess frame opacities | |
| FRAME_CREST: 0.64, | |
| FRAME_SURFACE: 0.12, | |
| FRAME_INTERIOR: 0.04, | |
| // Grid and point opacities | |
| POINT_DEFAULT: 0.1, | |
| POINT_HOVERED: 0.8, | |
| POINT_AXIS_ALIGNED: 0.8, | |
| POINT_AIR_PATCH: 0.24, // Semi-transparent for liberty visualization | |
| PREVIEW_STONE: 0.5, | |
| PREVIEW_JOINT_BLACK: 0.5, | |
| PREVIEW_JOINT_WHITE: 0.6, | |
| DIMMED: 0.3, | |
| DOMAIN_BLACK: 0.3, | |
| DOMAIN_WHITE: 0.3 | |
| } as const; | |
| // Shininess constants for stone materials | |
| const SHININESS = { | |
| STONE_BLACK: 120, | |
| STONE_WHITE: 30 | |
| } as const; | |
| // Geometric size constants | |
| const SIZES = { | |
| // Stone and point sizes (relative to grid spacing) | |
| STONE_RADIUS: 0.28, | |
| INTERSECTION_POINT_RADIUS: 0.16, | |
| JOINT_RADIUS: 0.12, | |
| JOINT_LENGTH: 0.47, | |
| DOMAIN_CUBE_SIZE: 0.6, | |
| // Sphere detail (number of segments) | |
| STONE_SEGMENTS: 32, | |
| POINT_SEGMENTS: 8, | |
| JOINT_SEGMENTS: 6 | |
| } as const; | |
| // Camera and scene constants | |
| const CAMERA = { | |
| FOV: 70, | |
| NEAR: 0.1, | |
| FAR: 1000, | |
| DISTANCE_MULTIPLIER: 1.1, | |
| HEIGHT_RATIO: 0.8 | |
| } as const; | |
| // Lighting intensity constants | |
| const LIGHTING = { | |
| AMBIENT_INTENSITY: 0.2, | |
| DIRECTIONAL_MAIN_INTENSITY: 0.8, | |
| DIRECTIONAL_FILL_INTENSITY: 0.3, | |
| HEMISPHERE_INTENSITY: 0.8 | |
| } as const; | |
| // Fog constants | |
| const FOG = { | |
| NEAR_FACTOR: 0.2, | |
| FAR_FACTOR: 0.8, | |
| MIN_NEAR: 0.1 | |
| } as const; | |
| // Last stone highlight constants | |
| const SHINING = { | |
| FLICKER_SPEED: 0.0048, | |
| EMISSIVE_COLOR: [0.03, 0.32, 0.6], | |
| BASE_INTENSITY_WHITE: 0.2, | |
| BASE_INTENSITY_BLACK: 0.06, | |
| FLICKER_INTENSITY_WHITE: 0.6, | |
| FLICKER_INTENSITY_BLACK: 0.1 | |
| } as const; | |
| export interface Stone { | |
| position: { x: number; y: number; z: number }; | |
| color: "black" | "white"; | |
| mesh?: THREE.Mesh; | |
| } | |
| export interface ViewportCallbacks { | |
| onStoneClick?: (x: number, y: number, z: number) => void; | |
| onPositionHover?: (x: number | null, y: number | null, z: number | null) => void; | |
| isPositionDroppable?: (x: number, y: number, z: number) => boolean; | |
| onInspectGroup?: (groupSize: number, liberties: number) => void; | |
| } | |
| export class TrigoViewport { | |
| private canvas: HTMLCanvasElement; | |
| private scene: THREE.Scene; | |
| private camera: THREE.PerspectiveCamera; | |
| private renderer: THREE.WebGLRenderer; | |
| private controls!: OrbitControls; | |
| private raycaster: THREE.Raycaster; | |
| private mouse: THREE.Vector2; | |
| private boardShape: BoardShape; | |
| private gridSpacing: number = 2; | |
| private gridGroup: THREE.Group; | |
| private stonesGroup: THREE.Group; | |
| private jointsGroup: THREE.Group; | |
| private intersectionPoints: THREE.Group; | |
| private domainCubesGroup: THREE.Group; | |
| private highlightedPoint: THREE.Mesh | null = null; | |
| private highlightedAxisPoints: THREE.Mesh[] = []; | |
| private previewStone: THREE.Mesh | null = null; | |
| private lastPlacedStone: { x: number; y: number; z: number } | null = null; | |
| private hoveredPosition: { x: number; y: number; z: number } | null = null; | |
| private stones: Map<string, Stone> = new Map(); | |
| private joints: Map<string, { X?: THREE.Mesh; Y?: THREE.Mesh; Z?: THREE.Mesh }> = new Map(); | |
| private domainCubes: Map<string, THREE.Mesh> = new Map(); | |
| private callbacks: ViewportCallbacks; | |
| private animationId: number | null = null; | |
| private isDestroyed: boolean = false; | |
| private currentPlayerColor: "black" | "white" = "black"; | |
| private isGameActive: boolean = false; | |
| private lastCameraDistance: number = 0; | |
| // Mouse drag detection | |
| private isMouseDown: boolean = false; | |
| private mouseDownPosition: { x: number; y: number } | null = null; | |
| private hasDragged: boolean = false; | |
| private dragThreshold: number = 5; // pixels | |
| // Inspect mode for analyzing stone groups | |
| private inspectMode: boolean = false; | |
| private ctrlKeyDown: boolean = false; | |
| private highlightedGroup: Set<string> | null = null; | |
| private airPatch: Set<string> | null = null; // Liberty positions for highlighted group | |
| private lastMouseEvent: MouseEvent | null = null; | |
| // Domain visibility for territory display | |
| private blackDomainVisible: boolean = false; | |
| private whiteDomainVisible: boolean = false; | |
| private blackDomain: Set<string> | null = null; | |
| private whiteDomain: Set<string> | null = null; | |
| constructor( | |
| canvas: HTMLCanvasElement, | |
| boardShape: BoardShape = { x: 5, y: 5, z: 5 }, | |
| callbacks: ViewportCallbacks = {} | |
| ) { | |
| this.canvas = canvas; | |
| this.boardShape = boardShape; | |
| this.callbacks = callbacks; | |
| // Initialize Three.js components | |
| this.scene = new THREE.Scene(); | |
| this.camera = new THREE.PerspectiveCamera( | |
| CAMERA.FOV, | |
| canvas.clientWidth / canvas.clientHeight, | |
| CAMERA.NEAR, | |
| CAMERA.FAR | |
| ); | |
| this.renderer = new THREE.WebGLRenderer({ | |
| canvas, | |
| antialias: true, | |
| alpha: true | |
| }); | |
| this.raycaster = new THREE.Raycaster(); | |
| this.mouse = new THREE.Vector2(); | |
| // Groups for organizing scene objects | |
| this.gridGroup = new THREE.Group(); | |
| this.stonesGroup = new THREE.Group(); | |
| this.jointsGroup = new THREE.Group(); | |
| this.intersectionPoints = new THREE.Group(); | |
| this.domainCubesGroup = new THREE.Group(); | |
| this.initialize(); | |
| } | |
| private initialize(): void { | |
| // Setup renderer - use getBoundingClientRect for accurate CSS size | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.renderer.setSize(rect.width, rect.height, false); | |
| this.renderer.setPixelRatio(window.devicePixelRatio); | |
| this.renderer.setClearColor(COLORS.SCENE_CLEAR, 1); | |
| // Setup camera - use max dimension for distance calculation | |
| const maxDim = Math.max(this.boardShape.x, this.boardShape.y, this.boardShape.z); | |
| const distance = maxDim * this.gridSpacing * CAMERA.DISTANCE_MULTIPLIER; | |
| this.camera.position.set(distance, distance * CAMERA.HEIGHT_RATIO, distance); | |
| this.camera.lookAt(0, 0, 0); | |
| // Setup controls | |
| this.controls = new OrbitControls(this.camera, this.canvas); | |
| this.controls.enableDamping = true; | |
| this.controls.dampingFactor = 0.05; | |
| this.controls.minDistance = 5; | |
| this.controls.maxDistance = 100; | |
| this.controls.maxPolarAngle = Math.PI / 2 + Math.PI / 4; | |
| this.controls.enablePan = false; // Disable camera panning with right mouse button | |
| // Setup scene | |
| this.scene.background = new THREE.Color(COLORS.SCENE_BACKGROUND); | |
| this.setupFog(); | |
| this.setupLighting(); | |
| this.createGrid(); | |
| this.createIntersectionPoints(); | |
| this.createJoints(); | |
| this.createDomainCubes(); | |
| this.createPreviewStone(); | |
| // Add groups to scene | |
| this.scene.add(this.gridGroup); | |
| this.scene.add(this.stonesGroup); | |
| this.scene.add(this.jointsGroup); | |
| this.scene.add(this.intersectionPoints); | |
| this.scene.add(this.domainCubesGroup); | |
| // Event listeners | |
| this.canvas.addEventListener("mousemove", this.onMouseMove.bind(this)); | |
| this.canvas.addEventListener("mousedown", this.onMouseDown.bind(this)); | |
| this.canvas.addEventListener("mouseup", this.onMouseUp.bind(this)); | |
| this.canvas.addEventListener("click", this.onClick.bind(this)); | |
| window.addEventListener("resize", this.onWindowResize.bind(this)); | |
| window.addEventListener("keydown", this.onKeyDown.bind(this)); | |
| window.addEventListener("keyup", this.onKeyUp.bind(this)); | |
| // Start animation loop | |
| this.animate(); | |
| } | |
| private createPreviewStone(): void { | |
| const geometry = new THREE.SphereGeometry( | |
| SIZES.STONE_RADIUS * this.gridSpacing, | |
| SIZES.STONE_SEGMENTS, | |
| SIZES.STONE_SEGMENTS | |
| ); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: this.currentPlayerColor === "black" ? COLORS.STONE_BLACK : COLORS.STONE_WHITE, | |
| transparent: true, | |
| opacity: OPACITY.PREVIEW_STONE, | |
| shininess: | |
| this.currentPlayerColor === "black" ? SHININESS.STONE_BLACK : SHININESS.STONE_WHITE | |
| }); | |
| this.previewStone = new THREE.Mesh(geometry, material); | |
| this.previewStone.visible = false; // Hidden by default | |
| this.scene.add(this.previewStone); | |
| } | |
| private updatePreviewStoneColor(): void { | |
| if (!this.previewStone) return; | |
| const material = this.previewStone.material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| this.currentPlayerColor === "black" ? COLORS.STONE_BLACK : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| this.currentPlayerColor === "black" ? SHININESS.STONE_BLACK : SHININESS.STONE_WHITE; | |
| } | |
| private setupFog(): void { | |
| // Use scene background color for fog | |
| this.scene.fog = new THREE.Fog(COLORS.SCENE_BACKGROUND, 0, 1); | |
| // Update fog parameters based on current camera position | |
| this.updateFog(true); | |
| } | |
| private highlightAxisPoints(gridX: number, gridY: number, gridZ: number): void { | |
| // Clear previous axis highlights | |
| this.clearAxisHighlights(); | |
| // Highlight points along the same axes | |
| this.intersectionPoints.children.forEach((child) => { | |
| const point = child as THREE.Mesh; | |
| const { gridX: px, gridY: py, gridZ: pz } = point.userData; | |
| // Check if point is on the same X, Y, or Z axis | |
| const alignedXAxis = px === gridX; | |
| const alignedYAxis = py === gridY; | |
| const alignedZAxis = pz === gridZ; | |
| const aligned = Number(alignedXAxis) + Number(alignedYAxis) + Number(alignedZAxis); | |
| // Highlight if on one axis | |
| if (aligned == 2) { | |
| const material = point.material as THREE.MeshBasicMaterial; | |
| material.color.set(COLORS.POINT_AXIS_ALIGNED); | |
| material.opacity = OPACITY.POINT_AXIS_ALIGNED; | |
| this.highlightedAxisPoints.push(point); | |
| } | |
| }); | |
| } | |
| private clearAxisHighlights(): void { | |
| // Reset all previously highlighted axis points | |
| this.highlightedAxisPoints.forEach((point) => { | |
| const material = point.material as THREE.MeshBasicMaterial; | |
| material.color.set(COLORS.POINT_DEFAULT); | |
| material.opacity = OPACITY.POINT_DEFAULT; | |
| }); | |
| this.highlightedAxisPoints = []; | |
| } | |
| private setupLighting(): void { | |
| // Ambient light | |
| const ambientLight = new THREE.AmbientLight( | |
| COLORS.AMBIENT_LIGHT, | |
| LIGHTING.AMBIENT_INTENSITY | |
| ); | |
| this.scene.add(ambientLight); | |
| // Directional light (main) | |
| const directionalLight1 = new THREE.DirectionalLight( | |
| COLORS.DIRECTIONAL_LIGHT, | |
| LIGHTING.DIRECTIONAL_MAIN_INTENSITY | |
| ); | |
| directionalLight1.position.set(10, 20, 10); | |
| directionalLight1.castShadow = true; | |
| this.scene.add(directionalLight1); | |
| // Directional light (fill) | |
| const directionalLight2 = new THREE.DirectionalLight( | |
| COLORS.DIRECTIONAL_LIGHT, | |
| LIGHTING.DIRECTIONAL_FILL_INTENSITY | |
| ); | |
| directionalLight2.position.set(-10, -10, -10); | |
| this.scene.add(directionalLight2); | |
| // Hemisphere light for softer ambient | |
| const hemisphereLight = new THREE.HemisphereLight( | |
| COLORS.HEMISPHERE_LIGHT_SKY, | |
| COLORS.HEMISPHERE_LIGHT_GROUND, | |
| LIGHTING.HEMISPHERE_INTENSITY | |
| ); | |
| hemisphereLight.position.set(0, 20, 0); | |
| this.scene.add(hemisphereLight); | |
| } | |
| private createGrid(): void { | |
| const { x: sizeX, y: sizeY, z: sizeZ } = this.boardShape; | |
| const spacing = this.gridSpacing; | |
| const offsetX = ((sizeX - 1) * spacing) / 2; | |
| const offsetY = ((sizeY - 1) * spacing) / 2; | |
| const offsetZ = ((sizeZ - 1) * spacing) / 2; | |
| // Chess frame materials - three-tiered system from prototype | |
| // Crest: edges/corners (most visible) | |
| const crestMaterial = new THREE.LineBasicMaterial({ | |
| color: COLORS.FRAME_CREST, | |
| opacity: OPACITY.FRAME_CREST, | |
| transparent: true | |
| }); | |
| // Surface: edges on faces (medium visibility) | |
| const surfaceMaterial = new THREE.LineBasicMaterial({ | |
| color: COLORS.FRAME_SURFACE, | |
| opacity: OPACITY.FRAME_SURFACE, | |
| transparent: true | |
| }); | |
| // Interior: inner lines (least visible) | |
| const interiorMaterial = new THREE.LineBasicMaterial({ | |
| color: COLORS.FRAME_INTERIOR, | |
| opacity: OPACITY.FRAME_INTERIOR, | |
| transparent: true | |
| }); | |
| // Helper function to determine material based on border conditions | |
| const getLineMaterial = (border1: boolean, border2: boolean): THREE.LineBasicMaterial => { | |
| if (border1 && border2) return crestMaterial; // Both borders -> crest | |
| if (border1 || border2) return surfaceMaterial; // One border -> surface | |
| return interiorMaterial; // No borders -> interior | |
| }; | |
| // X-axis lines (parallel to X) | |
| for (let y = 0; y < sizeY; y++) { | |
| for (let z = 0; z < sizeZ; z++) { | |
| const border1 = y === 0 || y === sizeY - 1; | |
| const border2 = z === 0 || z === sizeZ - 1; | |
| const material = getLineMaterial(border1, border2); | |
| const points = []; | |
| for (let x = 0; x < sizeX; x++) { | |
| points.push( | |
| new THREE.Vector3( | |
| x * spacing - offsetX, | |
| y * spacing - offsetY, | |
| z * spacing - offsetZ | |
| ) | |
| ); | |
| } | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| const line = new THREE.Line(geometry, material); | |
| this.gridGroup.add(line); | |
| } | |
| } | |
| // Y-axis lines (parallel to Y) | |
| for (let x = 0; x < sizeX; x++) { | |
| for (let z = 0; z < sizeZ; z++) { | |
| const border1 = x === 0 || x === sizeX - 1; | |
| const border2 = z === 0 || z === sizeZ - 1; | |
| const material = getLineMaterial(border1, border2); | |
| const points = []; | |
| for (let y = 0; y < sizeY; y++) { | |
| points.push( | |
| new THREE.Vector3( | |
| x * spacing - offsetX, | |
| y * spacing - offsetY, | |
| z * spacing - offsetZ | |
| ) | |
| ); | |
| } | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| const line = new THREE.Line(geometry, material); | |
| this.gridGroup.add(line); | |
| } | |
| } | |
| // Z-axis lines (parallel to Z) | |
| if (sizeZ >= 3) { | |
| for (let x = 0; x < sizeX; x++) { | |
| for (let y = 0; y < sizeY; y++) { | |
| const border1 = x === 0 || x === sizeX - 1; | |
| const border2 = y === 0 || y === sizeY - 1; | |
| const material = getLineMaterial(border1, border2); | |
| const points = []; | |
| for (let z = 0; z < sizeZ; z++) { | |
| points.push( | |
| new THREE.Vector3( | |
| x * spacing - offsetX, | |
| y * spacing - offsetY, | |
| z * spacing - offsetZ | |
| ) | |
| ); | |
| } | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| const line = new THREE.Line(geometry, material); | |
| this.gridGroup.add(line); | |
| } | |
| } | |
| } | |
| // Add axes helper for orientation | |
| const maxOffset = Math.max(offsetX, offsetY, offsetZ); | |
| if (sizeZ >= 3) { | |
| // Show all three axes for 3D boards | |
| const axesHelper = new THREE.AxesHelper(maxOffset * 1.2); | |
| this.gridGroup.add(axesHelper); | |
| } else { | |
| // Show only X and Y axes for 2D boards (hide Z axis) | |
| const axisLength = maxOffset * 1.2; | |
| const axesMaterial = [ | |
| new THREE.LineBasicMaterial({ color: 0xff0000 }), // X axis - red | |
| new THREE.LineBasicMaterial({ color: 0x00ff00 }) // Y axis - green | |
| ]; | |
| // X axis | |
| const xPoints = [new THREE.Vector3(0, 0, 0), new THREE.Vector3(axisLength, 0, 0)]; | |
| const xGeometry = new THREE.BufferGeometry().setFromPoints(xPoints); | |
| const xLine = new THREE.Line(xGeometry, axesMaterial[0]); | |
| this.gridGroup.add(xLine); | |
| // Y axis | |
| const yPoints = [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, axisLength, 0)]; | |
| const yGeometry = new THREE.BufferGeometry().setFromPoints(yPoints); | |
| const yLine = new THREE.Line(yGeometry, axesMaterial[1]); | |
| this.gridGroup.add(yLine); | |
| } | |
| } | |
| private createIntersectionPoints(): void { | |
| const { x: sizeX, y: sizeY, z: sizeZ } = this.boardShape; | |
| const spacing = this.gridSpacing; | |
| const offsetX = ((sizeX - 1) * spacing) / 2; | |
| const offsetY = ((sizeY - 1) * spacing) / 2; | |
| const offsetZ = ((sizeZ - 1) * spacing) / 2; | |
| // Create small spheres at each grid intersection | |
| const pointGeometry = new THREE.SphereGeometry( | |
| SIZES.INTERSECTION_POINT_RADIUS, | |
| SIZES.POINT_SEGMENTS, | |
| SIZES.POINT_SEGMENTS | |
| ); | |
| for (let x = 0; x < sizeX; x++) { | |
| for (let y = 0; y < sizeY; y++) { | |
| for (let z = 0; z < sizeZ; z++) { | |
| // Create a unique material for each point so they can be styled independently | |
| const pointMaterial = new THREE.MeshBasicMaterial({ | |
| color: COLORS.POINT_DEFAULT, | |
| opacity: OPACITY.POINT_DEFAULT, | |
| transparent: true | |
| }); | |
| const point = new THREE.Mesh(pointGeometry, pointMaterial); | |
| point.position.set( | |
| x * spacing - offsetX, | |
| y * spacing - offsetY, | |
| z * spacing - offsetZ | |
| ); | |
| point.userData = { gridX: x, gridY: y, gridZ: z }; | |
| this.intersectionPoints.add(point); | |
| } | |
| } | |
| } | |
| } | |
| private createJoints(): void { | |
| const { x: sizeX, y: sizeY, z: sizeZ } = this.boardShape; | |
| const spacing = this.gridSpacing; | |
| const offsetX = ((sizeX - 1) * spacing) / 2; | |
| const offsetY = ((sizeY - 1) * spacing) / 2; | |
| const offsetZ = ((sizeZ - 1) * spacing) / 2; | |
| // Joint dimensions from prototype: scale (0.06, 0.47, 0.06) | |
| const jointRadius = SIZES.JOINT_RADIUS; | |
| const jointLength = SIZES.JOINT_LENGTH * spacing; // Scale by grid spacing | |
| // Create joints for each grid position | |
| for (let x = 0; x < sizeX; x++) { | |
| for (let y = 0; y < sizeY; y++) { | |
| for (let z = 0; z < sizeZ; z++) { | |
| const key = this.getStoneKey(x, y, z); | |
| const jointNodes: { X?: THREE.Mesh; Y?: THREE.Mesh; Z?: THREE.Mesh } = {}; | |
| // Create X-axis joint (between current and next X position) | |
| if (x < sizeX - 1) { | |
| const geometry = new THREE.CylinderGeometry( | |
| jointRadius, | |
| jointRadius, | |
| jointLength, | |
| SIZES.JOINT_SEGMENTS | |
| ); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: COLORS.STONE_BLACK, | |
| shininess: SHININESS.STONE_BLACK, | |
| specular: COLORS.STONE_BLACK_SPECULAR | |
| }); | |
| const joint = new THREE.Mesh(geometry, material); | |
| // Position at midpoint between stones, rotate to align with X-axis | |
| joint.position.set( | |
| (x + 0.5) * spacing - offsetX, | |
| y * spacing - offsetY, | |
| z * spacing - offsetZ | |
| ); | |
| joint.rotation.set(0, 0, Math.PI / 2); // Rotate to X-axis | |
| joint.visible = false; // Hidden by default | |
| this.jointsGroup.add(joint); | |
| jointNodes.X = joint; | |
| } | |
| // Create Y-axis joint (between current and next Y position) | |
| if (y < sizeY - 1) { | |
| const geometry = new THREE.CylinderGeometry( | |
| jointRadius, | |
| jointRadius, | |
| jointLength, | |
| SIZES.JOINT_SEGMENTS | |
| ); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: COLORS.STONE_BLACK, | |
| shininess: SHININESS.STONE_BLACK, | |
| specular: COLORS.STONE_BLACK_SPECULAR | |
| }); | |
| const joint = new THREE.Mesh(geometry, material); | |
| // Position at midpoint between stones (Y-axis is already aligned) | |
| joint.position.set( | |
| x * spacing - offsetX, | |
| (y + 0.5) * spacing - offsetY, | |
| z * spacing - offsetZ | |
| ); | |
| // No rotation needed for Y-axis (cylinder default orientation) | |
| joint.visible = false; // Hidden by default | |
| this.jointsGroup.add(joint); | |
| jointNodes.Y = joint; | |
| } | |
| // Create Z-axis joint (between current and next Z position) | |
| if (z < sizeZ - 1) { | |
| const geometry = new THREE.CylinderGeometry( | |
| jointRadius, | |
| jointRadius, | |
| jointLength, | |
| SIZES.JOINT_SEGMENTS | |
| ); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: COLORS.STONE_BLACK, | |
| shininess: SHININESS.STONE_BLACK, | |
| specular: COLORS.STONE_BLACK_SPECULAR | |
| }); | |
| const joint = new THREE.Mesh(geometry, material); | |
| // Position at midpoint between stones, rotate to align with Z-axis | |
| joint.position.set( | |
| x * spacing - offsetX, | |
| y * spacing - offsetY, | |
| (z + 0.5) * spacing - offsetZ | |
| ); | |
| joint.rotation.set(Math.PI / 2, 0, 0); // Rotate to Z-axis | |
| joint.visible = false; // Hidden by default | |
| this.jointsGroup.add(joint); | |
| jointNodes.Z = joint; | |
| } | |
| this.joints.set(key, jointNodes); | |
| } | |
| } | |
| } | |
| } | |
| private createDomainCubes(): void { | |
| const { x: sizeX, y: sizeY, z: sizeZ } = this.boardShape; | |
| const spacing = this.gridSpacing; | |
| const offsetX = ((sizeX - 1) * spacing) / 2; | |
| const offsetY = ((sizeY - 1) * spacing) / 2; | |
| const offsetZ = ((sizeZ - 1) * spacing) / 2; | |
| // Domain cube size from prototype: scale 0.6 | |
| const cubeSize = SIZES.DOMAIN_CUBE_SIZE * spacing; | |
| // Create domain cubes for each grid position | |
| for (let x = 0; x < sizeX; x++) { | |
| for (let y = 0; y < sizeY; y++) { | |
| for (let z = 0; z < sizeZ; z++) { | |
| const key = this.getStoneKey(x, y, z); | |
| // Create cube geometry | |
| const geometry = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize); | |
| // Create material (will be updated dynamically based on domain type) | |
| const material = new THREE.MeshBasicMaterial({ | |
| color: COLORS.STONE_BLACK, | |
| transparent: true, | |
| opacity: OPACITY.DOMAIN_BLACK, | |
| depthWrite: false // Prevent z-fighting with stones | |
| }); | |
| const cube = new THREE.Mesh(geometry, material); | |
| // Position at grid intersection | |
| cube.position.set( | |
| x * spacing - offsetX, | |
| y * spacing - offsetY, | |
| z * spacing - offsetZ | |
| ); | |
| cube.visible = false; // Hidden by default | |
| this.domainCubesGroup.add(cube); | |
| this.domainCubes.set(key, cube); | |
| } | |
| } | |
| } | |
| } | |
| private getStoneKey(x: number, y: number, z: number): string { | |
| return `${x},${y},${z}`; | |
| } | |
| public addStone(x: number, y: number, z: number, color: "black" | "white"): void { | |
| const key = this.getStoneKey(x, y, z); | |
| if (this.stones.has(key)) { | |
| console.warn(`Stone already exists at (${x}, ${y}, ${z})`); | |
| return; | |
| } | |
| // Hide preview stone immediately when adding a new stone | |
| this.hidePreviewStone(); | |
| const spacing = this.gridSpacing; | |
| const offsetX = ((this.boardShape.x - 1) * spacing) / 2; | |
| const offsetY = ((this.boardShape.y - 1) * spacing) / 2; | |
| const offsetZ = ((this.boardShape.z - 1) * spacing) / 2; | |
| // Create stone geometry | |
| const geometry = new THREE.SphereGeometry( | |
| SIZES.STONE_RADIUS * this.gridSpacing, | |
| SIZES.STONE_SEGMENTS, | |
| SIZES.STONE_SEGMENTS | |
| ); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: color === "black" ? COLORS.STONE_BLACK : COLORS.STONE_WHITE, | |
| shininess: color === "black" ? SHININESS.STONE_BLACK : SHININESS.STONE_WHITE, | |
| specular: color === "black" ? COLORS.STONE_BLACK_SPECULAR : COLORS.STONE_WHITE_SPECULAR, | |
| emissive: 0x000000, // Will be animated for last placed stone | |
| emissiveIntensity: 0 | |
| }); | |
| const stoneMesh = new THREE.Mesh(geometry, material); | |
| stoneMesh.position.set(x * spacing - offsetX, y * spacing - offsetY, z * spacing - offsetZ); | |
| const stone: Stone = { | |
| position: { x, y, z }, | |
| color, | |
| mesh: stoneMesh | |
| }; | |
| this.stones.set(key, stone); | |
| this.stonesGroup.add(stoneMesh); | |
| // Clear emissive from previous last placed stone | |
| if (this.lastPlacedStone) { | |
| const prevKey = this.getStoneKey( | |
| this.lastPlacedStone.x, | |
| this.lastPlacedStone.y, | |
| this.lastPlacedStone.z | |
| ); | |
| const prevStone = this.stones.get(prevKey); | |
| if (prevStone && prevStone.mesh) { | |
| const prevMaterial = prevStone.mesh.material as THREE.MeshPhongMaterial; | |
| prevMaterial.emissive.set(0x000000); | |
| prevMaterial.emissiveIntensity = 0; | |
| } | |
| } | |
| // Track this as the last placed stone | |
| this.lastPlacedStone = { x, y, z }; | |
| // Update joints to show connections | |
| this.refreshJoints(); | |
| } | |
| public removeStone(x: number, y: number, z: number): void { | |
| const key = this.getStoneKey(x, y, z); | |
| const stone = this.stones.get(key); | |
| if (stone && stone.mesh) { | |
| this.stonesGroup.remove(stone.mesh); | |
| stone.mesh.geometry.dispose(); | |
| if (stone.mesh.material instanceof THREE.Material) { | |
| stone.mesh.material.dispose(); | |
| } | |
| this.stones.delete(key); | |
| // Update joints after removing stone | |
| this.refreshJoints(); | |
| } | |
| } | |
| public clearBoard(): void { | |
| // Remove all stones | |
| this.stones.forEach((stone) => { | |
| if (stone.mesh) { | |
| this.stonesGroup.remove(stone.mesh); | |
| stone.mesh.geometry.dispose(); | |
| if (stone.mesh.material instanceof THREE.Material) { | |
| stone.mesh.material.dispose(); | |
| } | |
| } | |
| }); | |
| this.stones.clear(); | |
| this.lastPlacedStone = null; | |
| // Hide all joints | |
| this.refreshJoints(); | |
| } | |
| public hasStone(x: number, y: number, z: number): boolean { | |
| return this.stones.has(this.getStoneKey(x, y, z)); | |
| } | |
| private refreshJoints(): void { | |
| const { x: sizeX, y: sizeY, z: sizeZ } = this.boardShape; | |
| for (let x = 0; x < sizeX; x++) { | |
| for (let y = 0; y < sizeY; y++) { | |
| for (let z = 0; z < sizeZ; z++) { | |
| const key = this.getStoneKey(x, y, z); | |
| const jointNodes = this.joints.get(key); | |
| if (!jointNodes) continue; | |
| const centerStone = this.stones.get(key); | |
| // X-axis joint: check if current and (x+1) have same color | |
| if (jointNodes.X) { | |
| if (centerStone && x + 1 < sizeX) { | |
| const nextKey = this.getStoneKey(x + 1, y, z); | |
| const nextStone = this.stones.get(nextKey); | |
| if (nextStone && nextStone.color === centerStone.color) { | |
| const material = jointNodes.X.material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| centerStone.color === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| centerStone.color === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| centerStone.color === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = 1.0; | |
| material.transparent = false; | |
| jointNodes.X.visible = true; | |
| } else { | |
| jointNodes.X.visible = false; | |
| } | |
| } else { | |
| jointNodes.X.visible = false; | |
| } | |
| } | |
| // Y-axis joint: check if current and (y+1) have same color | |
| if (jointNodes.Y) { | |
| if (centerStone && y + 1 < sizeY) { | |
| const nextKey = this.getStoneKey(x, y + 1, z); | |
| const nextStone = this.stones.get(nextKey); | |
| if (nextStone && nextStone.color === centerStone.color) { | |
| const material = jointNodes.Y.material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| centerStone.color === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| centerStone.color === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| centerStone.color === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = 1.0; | |
| material.transparent = false; | |
| jointNodes.Y.visible = true; | |
| } else { | |
| jointNodes.Y.visible = false; | |
| } | |
| } else { | |
| jointNodes.Y.visible = false; | |
| } | |
| } | |
| // Z-axis joint: check if current and (z+1) have same color | |
| if (jointNodes.Z) { | |
| if (centerStone && z + 1 < sizeZ) { | |
| const nextKey = this.getStoneKey(x, y, z + 1); | |
| const nextStone = this.stones.get(nextKey); | |
| if (nextStone && nextStone.color === centerStone.color) { | |
| const material = jointNodes.Z.material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| centerStone.color === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| centerStone.color === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| centerStone.color === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = 1.0; | |
| material.transparent = false; | |
| jointNodes.Z.visible = true; | |
| } else { | |
| jointNodes.Z.visible = false; | |
| } | |
| } else { | |
| jointNodes.Z.visible = false; | |
| } | |
| } | |
| // Show preview joints if hovering over this position | |
| const isHovered = | |
| this.hoveredPosition && | |
| this.hoveredPosition.x === x && | |
| this.hoveredPosition.y === y && | |
| this.hoveredPosition.z === z; | |
| if (isHovered && this.isGameActive && !centerStone) { | |
| // Preview joints connecting to adjacent stones of current player's color | |
| const previewColor = this.currentPlayerColor; | |
| const previewOpacity = | |
| previewColor === "black" | |
| ? OPACITY.PREVIEW_JOINT_BLACK | |
| : OPACITY.PREVIEW_JOINT_WHITE; | |
| // Check -X direction (left neighbor) | |
| if (x > 0) { | |
| const leftKey = this.getStoneKey(x - 1, y, z); | |
| const leftStone = this.stones.get(leftKey); | |
| if (leftStone && leftStone.color === previewColor) { | |
| const leftJointNodes = this.joints.get(leftKey); | |
| if (leftJointNodes?.X) { | |
| const material = leftJointNodes.X | |
| .material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| previewColor === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = previewOpacity; | |
| material.transparent = true; | |
| leftJointNodes.X.visible = true; | |
| } | |
| } | |
| } | |
| // Check -Y direction (bottom neighbor) | |
| if (y > 0) { | |
| const bottomKey = this.getStoneKey(x, y - 1, z); | |
| const bottomStone = this.stones.get(bottomKey); | |
| if (bottomStone && bottomStone.color === previewColor) { | |
| const bottomJointNodes = this.joints.get(bottomKey); | |
| if (bottomJointNodes?.Y) { | |
| const material = bottomJointNodes.Y | |
| .material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| previewColor === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = previewOpacity; | |
| material.transparent = true; | |
| bottomJointNodes.Y.visible = true; | |
| } | |
| } | |
| } | |
| // Check -Z direction (back neighbor) | |
| if (z > 0) { | |
| const backKey = this.getStoneKey(x, y, z - 1); | |
| const backStone = this.stones.get(backKey); | |
| if (backStone && backStone.color === previewColor) { | |
| const backJointNodes = this.joints.get(backKey); | |
| if (backJointNodes?.Z) { | |
| const material = backJointNodes.Z | |
| .material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| previewColor === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = previewOpacity; | |
| material.transparent = true; | |
| backJointNodes.Z.visible = true; | |
| } | |
| } | |
| } | |
| // Check +X direction (right neighbor) | |
| if (x + 1 < sizeX && jointNodes.X) { | |
| const rightKey = this.getStoneKey(x + 1, y, z); | |
| const rightStone = this.stones.get(rightKey); | |
| if (rightStone && rightStone.color === previewColor) { | |
| const material = jointNodes.X.material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| previewColor === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = previewOpacity; | |
| material.transparent = true; | |
| jointNodes.X.visible = true; | |
| } | |
| } | |
| // Check +Y direction (top neighbor) | |
| if (y + 1 < sizeY && jointNodes.Y) { | |
| const topKey = this.getStoneKey(x, y + 1, z); | |
| const topStone = this.stones.get(topKey); | |
| if (topStone && topStone.color === previewColor) { | |
| const material = jointNodes.Y.material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| previewColor === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = previewOpacity; | |
| material.transparent = true; | |
| jointNodes.Y.visible = true; | |
| } | |
| } | |
| // Check +Z direction (front neighbor) | |
| if (z + 1 < sizeZ && jointNodes.Z) { | |
| const frontKey = this.getStoneKey(x, y, z + 1); | |
| const frontStone = this.stones.get(frontKey); | |
| if (frontStone && frontStone.color === previewColor) { | |
| const material = jointNodes.Z.material as THREE.MeshPhongMaterial; | |
| material.color.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK | |
| : COLORS.STONE_WHITE | |
| ); | |
| material.shininess = | |
| previewColor === "black" | |
| ? SHININESS.STONE_BLACK | |
| : SHININESS.STONE_WHITE; | |
| material.specular.set( | |
| previewColor === "black" | |
| ? COLORS.STONE_BLACK_SPECULAR | |
| : COLORS.STONE_WHITE_SPECULAR | |
| ); | |
| material.opacity = previewOpacity; | |
| material.transparent = true; | |
| jointNodes.Z.visible = true; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| public setCurrentPlayer(color: "black" | "white"): void { | |
| this.currentPlayerColor = color; | |
| this.updatePreviewStoneColor(); | |
| } | |
| public setGameActive(active: boolean): void { | |
| this.isGameActive = active; | |
| } | |
| public hidePreviewStone(): void { | |
| if (this.previewStone) { | |
| this.previewStone.visible = false; | |
| } | |
| this.hoveredPosition = null; | |
| this.refreshJoints(); | |
| } | |
| public setLastPlacedStone(x: number | null, y: number | null, z: number | null): void { | |
| // Clear previous stone's emissive glow | |
| if (this.lastPlacedStone) { | |
| const prevKey = this.getStoneKey( | |
| this.lastPlacedStone.x, | |
| this.lastPlacedStone.y, | |
| this.lastPlacedStone.z | |
| ); | |
| const prevStone = this.stones.get(prevKey); | |
| if (prevStone && prevStone.mesh) { | |
| const prevMaterial = prevStone.mesh.material as THREE.MeshPhongMaterial; | |
| prevMaterial.emissive.set(0x000000); | |
| prevMaterial.emissiveIntensity = 0; | |
| } | |
| } | |
| // Set new last placed stone | |
| if (x !== null && y !== null && z !== null) { | |
| this.lastPlacedStone = { x, y, z }; | |
| } else { | |
| this.lastPlacedStone = null; | |
| } | |
| } | |
| // Domain visibility methods (for territory display) | |
| public setBlackDomainVisible(visible: boolean): void { | |
| if (this.blackDomainVisible !== visible) { | |
| this.blackDomainVisible = visible; | |
| this.refreshDomainVisualization(); | |
| } | |
| } | |
| public setWhiteDomainVisible(visible: boolean): void { | |
| if (this.whiteDomainVisible !== visible) { | |
| this.whiteDomainVisible = visible; | |
| this.refreshDomainVisualization(); | |
| } | |
| } | |
| public setDomainData(blackDomain: Set<string> | null, whiteDomain: Set<string> | null): void { | |
| this.blackDomain = blackDomain; | |
| this.whiteDomain = whiteDomain; | |
| this.refreshDomainVisualization(); | |
| } | |
| private refreshDomainVisualization(): void { | |
| // In inspect mode, air patch takes priority over domain visualization | |
| if (this.inspectMode && this.airPatch) { | |
| this.updateDomainCubesVisualization(null, null); | |
| } else { | |
| // Normal mode: show domains based on visibility flags | |
| const black = this.blackDomainVisible ? this.blackDomain : null; | |
| const white = this.whiteDomainVisible ? this.whiteDomain : null; | |
| this.updateDomainCubesVisualization(black, white); | |
| } | |
| } | |
| public showDomainCubes(blackDomain: Set<string> | null, whiteDomain: Set<string> | null): void { | |
| this.setDomainData(blackDomain, whiteDomain); | |
| this.setBlackDomainVisible(true); | |
| this.setWhiteDomainVisible(true); | |
| } | |
| public hideDomainCubes(): void { | |
| this.setBlackDomainVisible(false); | |
| this.setWhiteDomainVisible(false); | |
| } | |
| public setBoardShape(shape: BoardShape): void { | |
| if ( | |
| shape.x === this.boardShape.x && | |
| shape.y === this.boardShape.y && | |
| shape.z === this.boardShape.z | |
| ) | |
| return; | |
| this.boardShape = shape; | |
| // Clear existing grid, points, and joints | |
| this.gridGroup.clear(); | |
| this.intersectionPoints.clear(); | |
| this.jointsGroup.clear(); | |
| this.joints.clear(); | |
| this.domainCubesGroup.clear(); | |
| this.domainCubes.clear(); | |
| this.clearBoard(); | |
| // Recreate grid, points, and joints | |
| this.createGrid(); | |
| this.createIntersectionPoints(); | |
| this.createJoints(); | |
| this.createDomainCubes(); | |
| // Update fog for new board size | |
| this.setupFog(); | |
| // Adjust camera position | |
| const maxDim = Math.max(shape.x, shape.y, shape.z); | |
| const distance = maxDim * this.gridSpacing * CAMERA.DISTANCE_MULTIPLIER; | |
| this.camera.position.set(distance, distance * CAMERA.HEIGHT_RATIO, distance); | |
| this.camera.lookAt(0, 0, 0); | |
| } | |
| private onMouseDown(event: MouseEvent): void { | |
| // Handle middle button (button 1) for inspect mode | |
| if (event.button === 1) { | |
| event.preventDefault(); | |
| this.inspectMode = true; | |
| this.updateHighlightedGroup(event); | |
| this.updateStoneOpacity(); | |
| return; | |
| } | |
| // Handle left button (button 0) | |
| if (event.button === 0) { | |
| this.isMouseDown = true; | |
| this.mouseDownPosition = { x: event.clientX, y: event.clientY }; | |
| this.hasDragged = false; | |
| // Exit inspect mode on left click | |
| if (this.inspectMode && !this.ctrlKeyDown) { | |
| this.inspectMode = false; | |
| this.highlightedGroup = null; | |
| this.airPatch = null; | |
| this.updateStoneOpacity(); | |
| // Clear tooltip by calling callback with 0, 0 | |
| if (this.callbacks.onInspectGroup) { | |
| this.callbacks.onInspectGroup(0, 0); | |
| } | |
| } | |
| } | |
| } | |
| private onMouseUp(event: MouseEvent): void { | |
| // Handle middle button release | |
| if (event.button === 1) { | |
| event.preventDefault(); | |
| if (this.inspectMode && !this.ctrlKeyDown) { | |
| this.inspectMode = false; | |
| this.highlightedGroup = null; | |
| this.airPatch = null; | |
| this.updateStoneOpacity(); | |
| // Clear tooltip by calling callback with 0, 0 | |
| if (this.callbacks.onInspectGroup) { | |
| this.callbacks.onInspectGroup(0, 0); | |
| } | |
| } | |
| return; | |
| } | |
| // Handle left button release | |
| if (event.button === 0) { | |
| this.isMouseDown = false; | |
| this.mouseDownPosition = null; | |
| // Note: hasDragged will be reset on next mousedown | |
| } | |
| } | |
| private onMouseMove(event: MouseEvent): void { | |
| // Store last mouse event for Ctrl key inspection | |
| this.lastMouseEvent = event; | |
| // If Ctrl is held and inspect mode is active, update highlighted group | |
| if (this.ctrlKeyDown && this.inspectMode) { | |
| this.updateHighlightedGroup(event); | |
| this.updateStoneOpacity(); | |
| } | |
| // Check if mouse is not hovering over canvas and cleanup | |
| if (!this.canvas.matches(":hover")) { | |
| // Hide preview stone when mouse is outside canvas | |
| if (this.previewStone) { | |
| this.previewStone.visible = false; | |
| } | |
| this.hoveredPosition = null; | |
| this.refreshJoints(); | |
| // Clear highlights when mouse is outside | |
| if (this.highlightedPoint) { | |
| const material = this.highlightedPoint.material as THREE.MeshBasicMaterial; | |
| material.color.set(COLORS.POINT_DEFAULT); | |
| material.opacity = OPACITY.POINT_DEFAULT; | |
| this.highlightedPoint = null; | |
| } | |
| this.clearAxisHighlights(); | |
| return; | |
| } | |
| // Check if we're dragging | |
| if (this.isMouseDown && this.mouseDownPosition) { | |
| const dx = event.clientX - this.mouseDownPosition.x; | |
| const dy = event.clientY - this.mouseDownPosition.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance > this.dragThreshold) { | |
| this.hasDragged = true; | |
| } | |
| } | |
| // Hide preview stone if dragging | |
| if (this.isMouseDown || this.hasDragged) { | |
| if (this.previewStone) { | |
| this.previewStone.visible = false; | |
| } | |
| this.hoveredPosition = null; | |
| this.refreshJoints(); | |
| // Clear highlights when dragging | |
| if (this.highlightedPoint) { | |
| const material = this.highlightedPoint.material as THREE.MeshBasicMaterial; | |
| material.color.set(COLORS.POINT_DEFAULT); | |
| material.opacity = OPACITY.POINT_DEFAULT; | |
| this.highlightedPoint = null; | |
| } | |
| this.clearAxisHighlights(); | |
| return; // Don't process hover logic when dragging | |
| } | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; | |
| this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; | |
| // Raycast to find intersection points | |
| this.raycaster.setFromCamera(this.mouse, this.camera); | |
| const intersects = this.raycaster.intersectObjects(this.intersectionPoints.children); | |
| // Remove previous highlight | |
| if (this.highlightedPoint) { | |
| (this.highlightedPoint.material as THREE.MeshBasicMaterial).color.set( | |
| COLORS.POINT_DEFAULT | |
| ); | |
| (this.highlightedPoint.material as THREE.MeshBasicMaterial).opacity = | |
| OPACITY.POINT_DEFAULT; | |
| this.highlightedPoint = null; | |
| } | |
| // Clear previous axis highlights | |
| this.clearAxisHighlights(); | |
| if (intersects.length > 0) { | |
| const intersect = intersects[0]; | |
| const point = intersect.object as THREE.Mesh; | |
| const { gridX, gridY, gridZ } = point.userData; | |
| // Check if there's already a stone at this position | |
| if (!this.hasStone(gridX, gridY, gridZ)) { | |
| // Check if position is droppable using game logic validation | |
| const isDroppable = | |
| this.isGameActive && | |
| (!this.callbacks.isPositionDroppable || | |
| this.callbacks.isPositionDroppable(gridX, gridY, gridZ)); | |
| this.highlightedPoint = point; | |
| // Use green for valid/droppable, red for invalid (game inactive or violates rules) | |
| const hoverColor = isDroppable | |
| ? COLORS.POINT_HOVERED | |
| : COLORS.POINT_HOVERED_DISABLED; | |
| (point.material as THREE.MeshBasicMaterial).color.set(hoverColor); | |
| (point.material as THREE.MeshBasicMaterial).opacity = OPACITY.POINT_HOVERED; | |
| // Highlight axis-aligned points | |
| this.highlightAxisPoints(gridX, gridY, gridZ); | |
| // Show preview stone only at droppable positions | |
| if (this.previewStone && isDroppable) { | |
| const spacing = this.gridSpacing; | |
| const offsetX = ((this.boardShape.x - 1) * spacing) / 2; | |
| const offsetY = ((this.boardShape.y - 1) * spacing) / 2; | |
| const offsetZ = ((this.boardShape.z - 1) * spacing) / 2; | |
| this.previewStone.position.set( | |
| gridX * spacing - offsetX, | |
| gridY * spacing - offsetY, | |
| gridZ * spacing - offsetZ | |
| ); | |
| this.previewStone.visible = true; | |
| // Update hovered position and refresh joints for preview | |
| this.hoveredPosition = { x: gridX, y: gridY, z: gridZ }; | |
| this.refreshJoints(); | |
| } else if (this.previewStone) { | |
| // Hide preview stone if position is not droppable | |
| this.previewStone.visible = false; | |
| this.hoveredPosition = null; | |
| this.refreshJoints(); | |
| } | |
| if (this.callbacks.onPositionHover) { | |
| this.callbacks.onPositionHover(gridX, gridY, gridZ); | |
| } | |
| } else { | |
| // Hide preview stone if position is occupied | |
| if (this.previewStone) { | |
| this.previewStone.visible = false; | |
| } | |
| this.hoveredPosition = null; | |
| this.refreshJoints(); | |
| if (this.callbacks.onPositionHover) { | |
| this.callbacks.onPositionHover(null, null, null); | |
| } | |
| } | |
| } else { | |
| // Hide preview stone when not hovering over grid | |
| if (this.previewStone) { | |
| this.previewStone.visible = false; | |
| } | |
| this.hoveredPosition = null; | |
| this.refreshJoints(); | |
| if (this.callbacks.onPositionHover) { | |
| this.callbacks.onPositionHover(null, null, null); | |
| } | |
| } | |
| // Only show pointer cursor when game is active and hovering over valid position | |
| const canPlaceStone = intersects.length > 0 && this.isGameActive; | |
| this.canvas.style.cursor = canPlaceStone ? "pointer" : "default"; | |
| } | |
| private onClick(event: MouseEvent): void { | |
| // Don't place stone if we've been dragging | |
| if (this.hasDragged) { | |
| this.hasDragged = false; // Reset for next interaction | |
| return; | |
| } | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; | |
| this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; | |
| this.raycaster.setFromCamera(this.mouse, this.camera); | |
| const intersects = this.raycaster.intersectObjects(this.intersectionPoints.children); | |
| if (intersects.length > 0) { | |
| const intersect = intersects[0]; | |
| const point = intersect.object as THREE.Mesh; | |
| const { gridX, gridY, gridZ } = point.userData; | |
| if (!this.hasStone(gridX, gridY, gridZ)) { | |
| if (this.callbacks.onStoneClick) { | |
| this.callbacks.onStoneClick(gridX, gridY, gridZ); | |
| } | |
| } | |
| } | |
| } | |
| private onWindowResize(): void { | |
| if (this.isDestroyed) return; | |
| // Use getBoundingClientRect to get actual CSS size | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const width = rect.width; | |
| const height = rect.height; | |
| this.camera.aspect = width / height; | |
| this.camera.updateProjectionMatrix(); | |
| // Use false as third parameter to prevent updating canvas style | |
| this.renderer.setSize(width, height, false); | |
| } | |
| private animate(): void { | |
| if (this.isDestroyed) return; | |
| this.animationId = requestAnimationFrame(() => this.animate()); | |
| // Update fog based on camera distance | |
| this.updateFog(); | |
| // Update last placed stone highlight effect only when mouse is over canvas | |
| // Using :hover pseudo-class check for better accuracy | |
| if (this.canvas.matches && this.canvas.matches(":hover")) { | |
| this.updateLastStoneHighlight(); | |
| } | |
| this.controls.update(); | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| private updateFog(forceUpdate: boolean = false): void { | |
| if (!this.scene.fog) return; | |
| // Get current camera distance from origin | |
| const cameraDistance = this.camera.position.length(); | |
| // Only update if camera distance changed (unless forced) | |
| if (!forceUpdate && Math.abs(cameraDistance - this.lastCameraDistance) < 0.01) return; | |
| this.lastCameraDistance = cameraDistance; | |
| // Calculate diagonal distance of the board | |
| const diagonal = Math.sqrt( | |
| this.boardShape.x ** 2 + this.boardShape.y ** 2 + this.boardShape.z ** 2 | |
| ); | |
| const boardDiagonal = diagonal * this.gridSpacing; | |
| // Update fog near and far based on camera distance +/- diagonal | |
| const fog = this.scene.fog as THREE.Fog; | |
| fog.near = Math.max(FOG.MIN_NEAR, cameraDistance - boardDiagonal * FOG.NEAR_FACTOR); | |
| fog.far = cameraDistance + boardDiagonal * FOG.FAR_FACTOR; | |
| } | |
| private updateLastStoneHighlight(): void { | |
| if (!this.lastPlacedStone) return; | |
| // Flicker function similar to prototype: sine wave animation | |
| const time = Date.now(); | |
| const flicker = Math.sin(time * SHINING.FLICKER_SPEED) / 2 + 0.5; | |
| // Get the last placed stone | |
| const key = this.getStoneKey( | |
| this.lastPlacedStone.x, | |
| this.lastPlacedStone.y, | |
| this.lastPlacedStone.z | |
| ); | |
| const stone = this.stones.get(key); | |
| if (stone && stone.mesh) { | |
| const material = stone.mesh.material as THREE.MeshPhongMaterial; | |
| // Cyan/blue glow color (matching prototype) | |
| const emissiveColor = new THREE.Color(...SHINING.EMISSIVE_COLOR); | |
| // Scale intensity based on stone color (white stones get brighter glow) | |
| const baseIntensity = | |
| stone.color === "white" | |
| ? SHINING.BASE_INTENSITY_WHITE | |
| : SHINING.BASE_INTENSITY_BLACK; | |
| const flickerIntensity = | |
| stone.color === "white" | |
| ? SHINING.FLICKER_INTENSITY_WHITE | |
| : SHINING.FLICKER_INTENSITY_BLACK; | |
| const intensity = flicker * flickerIntensity + baseIntensity; | |
| material.emissive = emissiveColor; | |
| material.emissiveIntensity = intensity; | |
| } | |
| } | |
| private updateHighlightedGroup(event: MouseEvent): void { | |
| // Find the stone group under the mouse cursor | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const mouse = new THREE.Vector2(); | |
| mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; | |
| mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; | |
| // Raycast to find clicked stone | |
| this.raycaster.setFromCamera(mouse, this.camera); | |
| const intersects = this.raycaster.intersectObjects(this.stonesGroup.children); | |
| if (intersects.length > 0) { | |
| // Find the position of the clicked stone | |
| let clickedPosition: { x: number; y: number; z: number } | null = null; | |
| for (const [, stone] of this.stones.entries()) { | |
| if (stone.mesh === intersects[0].object) { | |
| clickedPosition = stone.position; | |
| break; | |
| } | |
| } | |
| if (clickedPosition) { | |
| // Find the connected group using flood fill | |
| this.highlightedGroup = this.findConnectedGroup(clickedPosition); | |
| // Calculate liberties for the group | |
| const libertiesResult = this.calculateLiberties(this.highlightedGroup); | |
| this.airPatch = libertiesResult.positions; | |
| // Notify callback with group info | |
| if (this.callbacks.onInspectGroup) { | |
| this.callbacks.onInspectGroup( | |
| this.highlightedGroup.size, | |
| libertiesResult.count | |
| ); | |
| } | |
| } | |
| } else { | |
| this.highlightedGroup = null; | |
| this.airPatch = null; | |
| // Clear inspect info | |
| if (this.callbacks.onInspectGroup) { | |
| this.callbacks.onInspectGroup(0, 0); | |
| } | |
| } | |
| } | |
| private findConnectedGroup(startPos: { x: number; y: number; z: number }): Set<string> { | |
| const group = new Set<string>(); | |
| const startKey = this.getStoneKey(startPos.x, startPos.y, startPos.z); | |
| const startStone = this.stones.get(startKey); | |
| if (!startStone) return group; | |
| const color = startStone.color; | |
| const queue: { x: number; y: number; z: number }[] = [startPos]; | |
| const visited = new Set<string>(); | |
| while (queue.length > 0) { | |
| const pos = queue.shift()!; | |
| const key = this.getStoneKey(pos.x, pos.y, pos.z); | |
| if (visited.has(key)) continue; | |
| visited.add(key); | |
| const stone = this.stones.get(key); | |
| if (!stone || stone.color !== color) continue; | |
| group.add(key); | |
| // Check all 6 neighbors in 3D space | |
| const neighbors = [ | |
| { x: pos.x + 1, y: pos.y, z: pos.z }, | |
| { x: pos.x - 1, y: pos.y, z: pos.z }, | |
| { x: pos.x, y: pos.y + 1, z: pos.z }, | |
| { x: pos.x, y: pos.y - 1, z: pos.z }, | |
| { x: pos.x, y: pos.y, z: pos.z + 1 }, | |
| { x: pos.x, y: pos.y, z: pos.z - 1 } | |
| ]; | |
| for (const neighbor of neighbors) { | |
| // Check if neighbor is within board bounds | |
| if ( | |
| neighbor.x >= 0 && | |
| neighbor.x < this.boardShape.x && | |
| neighbor.y >= 0 && | |
| neighbor.y < this.boardShape.y && | |
| neighbor.z >= 0 && | |
| neighbor.z < this.boardShape.z | |
| ) { | |
| const neighborKey = this.getStoneKey(neighbor.x, neighbor.y, neighbor.z); | |
| if (!visited.has(neighborKey)) { | |
| queue.push(neighbor); | |
| } | |
| } | |
| } | |
| } | |
| return group; | |
| } | |
| private calculateLiberties(group: Set<string>): { count: number; positions: Set<string> } { | |
| const liberties = new Set<string>(); | |
| // For each stone in the group, check its neighbors for empty positions | |
| for (const key of group) { | |
| const parts = key.split(",").map(Number); | |
| const pos = { x: parts[0], y: parts[1], z: parts[2] }; | |
| // Check all 6 neighbors | |
| const neighbors = [ | |
| { x: pos.x + 1, y: pos.y, z: pos.z }, | |
| { x: pos.x - 1, y: pos.y, z: pos.z }, | |
| { x: pos.x, y: pos.y + 1, z: pos.z }, | |
| { x: pos.x, y: pos.y - 1, z: pos.z }, | |
| { x: pos.x, y: pos.y, z: pos.z + 1 }, | |
| { x: pos.x, y: pos.y, z: pos.z - 1 } | |
| ]; | |
| for (const neighbor of neighbors) { | |
| // Check if neighbor is within bounds | |
| if ( | |
| neighbor.x >= 0 && | |
| neighbor.x < this.boardShape.x && | |
| neighbor.y >= 0 && | |
| neighbor.y < this.boardShape.y && | |
| neighbor.z >= 0 && | |
| neighbor.z < this.boardShape.z | |
| ) { | |
| const neighborKey = this.getStoneKey(neighbor.x, neighbor.y, neighbor.z); | |
| // If neighbor is empty (not in stones map), it's a liberty | |
| if (!this.stones.has(neighborKey)) { | |
| liberties.add(neighborKey); | |
| } | |
| } | |
| } | |
| } | |
| return { count: liberties.size, positions: liberties }; | |
| } | |
| private updateStoneOpacity(): void { | |
| // Update opacity of all stones based on inspect mode | |
| this.stones.forEach((stone, key) => { | |
| if (!stone.mesh) return; | |
| const material = stone.mesh.material as THREE.MeshPhongMaterial; | |
| if (this.inspectMode && this.highlightedGroup) { | |
| // Dim stones not in highlighted group | |
| if (this.highlightedGroup.has(key)) { | |
| material.opacity = 1.0; | |
| material.transparent = false; | |
| } else { | |
| material.opacity = OPACITY.DIMMED; | |
| material.transparent = true; | |
| } | |
| } else { | |
| // Normal opacity | |
| material.opacity = 1.0; | |
| material.transparent = false; | |
| } | |
| }); | |
| // Update domain cubes first (respects visibility flags and inspect mode) | |
| this.refreshDomainVisualization(); | |
| // Update intersection point colors to show air patch (liberties) | |
| // This must come after domain cubes so it can check if cubes are visible | |
| this.updateAirPatchVisualization(); | |
| } | |
| private updateAirPatchVisualization(): void { | |
| // Reset all intersection points to default color | |
| this.intersectionPoints.children.forEach((child) => { | |
| const point = child as THREE.Mesh; | |
| const material = point.material as THREE.MeshBasicMaterial; | |
| const { gridX, gridY, gridZ } = point.userData; | |
| const key = this.getStoneKey(gridX, gridY, gridZ); | |
| // Check if domain cube is visible at this position | |
| const domainCube = this.domainCubes.get(key); | |
| const hasDomainCube = domainCube && domainCube.visible; | |
| // Check if this position is a liberty (air patch) | |
| if (this.inspectMode && this.airPatch && this.airPatch.has(key)) { | |
| if (hasDomainCube) { | |
| // Hide intersection point when domain cube is visible | |
| point.visible = false; | |
| } else { | |
| // Show liberty highlight when no domain cube | |
| point.visible = true; | |
| material.color.set(COLORS.POINT_AIR_PATCH); | |
| material.opacity = OPACITY.POINT_AIR_PATCH; | |
| } | |
| } else if (!this.stones.has(key)) { | |
| // Empty position not in air patch - reset to default | |
| point.visible = true; | |
| material.color.set(COLORS.POINT_DEFAULT); | |
| material.opacity = OPACITY.POINT_DEFAULT; | |
| } | |
| }); | |
| } | |
| private updateDomainCubesVisualization( | |
| blackDomain: Set<string> | null, | |
| whiteDomain: Set<string> | null | |
| ): void { | |
| // Update domain cube visibility based on territory and air patch | |
| this.domainCubes.forEach((cube, key) => { | |
| const material = cube.material as THREE.MeshBasicMaterial; | |
| // Priority: Air patch > Black domain > White domain > Hidden | |
| if (this.inspectMode && this.airPatch && this.airPatch.has(key)) { | |
| // Show air patch (liberty) with green color | |
| material.color.set(COLORS.POINT_AIR_PATCH); | |
| material.opacity = OPACITY.POINT_AIR_PATCH; | |
| cube.visible = true; | |
| } else if (blackDomain && blackDomain.has(key)) { | |
| // Show black territory | |
| material.color.set(COLORS.STONE_BLACK); | |
| material.opacity = OPACITY.DOMAIN_BLACK; | |
| cube.visible = true; | |
| } else if (whiteDomain && whiteDomain.has(key)) { | |
| // Show white territory | |
| material.color.set(COLORS.STONE_WHITE); | |
| material.opacity = OPACITY.DOMAIN_WHITE; | |
| cube.visible = true; | |
| } else { | |
| // Hide cube | |
| cube.visible = false; | |
| } | |
| }); | |
| } | |
| private onKeyDown(event: KeyboardEvent): void { | |
| // Ctrl key (17) or Meta key (91/93) for Mac | |
| if (event.ctrlKey || event.metaKey) { | |
| this.ctrlKeyDown = true; | |
| this.inspectMode = true; | |
| // Update highlighted group based on last mouse position if available | |
| if (this.lastMouseEvent) { | |
| this.updateHighlightedGroup(this.lastMouseEvent); | |
| } | |
| this.updateStoneOpacity(); | |
| } | |
| } | |
| private onKeyUp(event: KeyboardEvent): void { | |
| // Ctrl key release | |
| if (!event.ctrlKey && !event.metaKey) { | |
| this.ctrlKeyDown = false; | |
| if (this.inspectMode) { | |
| this.inspectMode = false; | |
| this.highlightedGroup = null; | |
| this.airPatch = null; | |
| this.updateStoneOpacity(); | |
| // Clear tooltip by calling callback with 0, 0 | |
| if (this.callbacks.onInspectGroup) { | |
| this.callbacks.onInspectGroup(0, 0); | |
| } | |
| } | |
| } | |
| } | |
| public destroy(): void { | |
| this.isDestroyed = true; | |
| // Cancel animation | |
| if (this.animationId !== null) { | |
| cancelAnimationFrame(this.animationId); | |
| } | |
| // Remove event listeners | |
| this.canvas.removeEventListener("mousemove", this.onMouseMove.bind(this)); | |
| this.canvas.removeEventListener("mousedown", this.onMouseDown.bind(this)); | |
| this.canvas.removeEventListener("mouseup", this.onMouseUp.bind(this)); | |
| this.canvas.removeEventListener("click", this.onClick.bind(this)); | |
| window.removeEventListener("resize", this.onWindowResize.bind(this)); | |
| window.removeEventListener("keydown", this.onKeyDown.bind(this)); | |
| window.removeEventListener("keyup", this.onKeyUp.bind(this)); | |
| // Dispose of Three.js resources | |
| this.clearBoard(); | |
| this.gridGroup.clear(); | |
| this.intersectionPoints.clear(); | |
| this.renderer.dispose(); | |
| this.controls.dispose(); | |
| } | |
| } | |