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 = new Map(); private joints: Map = new Map(); private domainCubes: Map = 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 | null = null; private airPatch: Set | 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 | null = null; private whiteDomain: Set | 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 | null, whiteDomain: Set | 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 | null, whiteDomain: Set | 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 { const group = new Set(); 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(); 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): { count: number; positions: Set } { const liberties = new Set(); // 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 | null, whiteDomain: Set | 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(); } }