trigo / trigo-web /app /src /services /trigoViewport.ts
k-l-lambda's picture
updated
502af73
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();
}
}