ONO::FW::Edu::Apps::JS::GeoCubesScript

package ONO::FW::Edu::Apps::JS::GeoCubesScript;
################################################################################
# COPYRIGHT / LICENSE #
################################################################################
#
# This file is part of the ONO Software Project.
#
# Copyright (C) 2022-2026 Jos Kirps and The Joopita Project [ASBL]
# Copyright (C) 2011-2022 Jos Kirps and Joopita Research ASBL
# Copyright (C) 2006-2011 Jos Kirps and The Joopita Project
# Copyright (C) 2001-2006 Jos Kirps and The WobbleWolf Project
#
# This file, as well as many other parts of the ONO Software Project or
# related elements, are FREE SOFTWARE available under the ARTISTIC LICENSE 2.0.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# For the full license, see /ono/osr/license/LICENSE.txt, or write to
# jos_AT_kirps_DOT_com or contact_AT_joopita_DOT_com.
#
################################################################################
# END OF COPYRIGHT / LICENSE, HERE COMES THE CODE ... #
################################################################################

#: This file contains an auto-generated module that have been compiled using pure HTML / JS files created for the Morzino Project (morzino.com) and that have been released as Open Source software.


use strict;

# This is an auto-generated file created by ONO_FW_Apps_Dev
# DIR = ono/sys/ONO/Ext/Morzino/Apps/JS, APP = GeoCubes, MOD = ONO::FW::Edu::Apps::Modules::GeoCubes
#
# Make sure this will pass mksys error checking: disable_subcall_checking_file
#

# INPUT VAR : gameMode
# INPUT VAR : boardSize
# INPUT VAR : minNumCubes
# INPUT VAR : maxNumCubes
# INPUT VAR : minNumStacks
# INPUT VAR : maxNumStacks
# INPUT VAR : maxStackHeight
# INPUT VAR : stacksConnected
# INPUT VAR : stacksRandomColors
# INPUT VAR : stacksNumbered
# ACTION : alert_you_won^correct_next^
# FILEDIR search in: ono/sys/ONO/Ext/Morzino/Apps/JS/GeoCubes
# FILEDIR found: Script


sub code {

my (
$self,
$gameMode,
$boardSize,
$minNumCubes,
$maxNumCubes,
$minNumStacks,
$maxNumStacks,
$maxStackHeight,
$stacksConnected,
$stacksRandomColors,
$stacksNumbered
) = @_;


return qq~



<div id="gameWrapper">
<div id="threeContainer"></div>
<canvas id="canvas2D" style="display: none;"></canvas>
<div id="full3DContainer" style="display: none;"></div>
</div>




<script>
// Configuration variables
const boardSize = $boardSize;
const minNumCubes = $minNumCubes;
const maxNumCubes = $maxNumCubes;
const minNumStacks = $minNumStacks;
const maxNumStacks = $maxNumStacks;
const maxStackHeight = $maxStackHeight;
const stacksConnected = $stacksConnected;
const stacksNumbered = $stacksNumbered;
const stacksRandomColors = $stacksRandomColors; // 0 = same color (orange), 1 = random colors per stack
const gameMode = $gameMode; // 0 = interactive puzzle, 1 = display with 2D numbers, 2 = display with no 2D numbers/colors, editable 2D
// 3 = 3D only view with pre-placed cubes and auto-rotation
// 3D Scene setup
const scaleFactor = 8 / boardSize; // Scale to match original 8x8 board size
const squareSize = 2 * scaleFactor; // Scale square size (e.g., 2.6667 for boardSize=6)
const boardTotalSize = boardSize * squareSize; // Total board size (e.g., 16 units)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75 / scaleFactor, 400 / 400, 0.1, 1000); // Adjust FOV
const renderer = new THREE.WebGLRenderer({ antialias: true });
// Scale renderer size to make board appear larger
const renderWidth = gameMode === 3 ? 400 * scaleFactor : 460;
const renderHeight = gameMode === 3 ? 400 * scaleFactor : 480;
renderer.setSize(renderWidth, renderHeight);
renderer.setClearColor(0x000000, 1); // Solid black background
const threeContainer = document.getElementById('threeContainer');
const full3DContainer = document.getElementById('full3DContainer');
const canvas2D = document.getElementById('canvas2D');

// Add class for styling the 3D canvas
renderer.domElement.classList.add('three-canvas');

// Set up container based on game mode
if (gameMode === 3) {
// Full screen 3D mode
full3DContainer.style.display = 'block';
full3DContainer.appendChild(renderer.domElement);
threeContainer.style.display = 'none';
canvas2D.style.display = 'none';
} else {
// Normal split mode
renderer.setSize(460, 480); // Keep original canvas size for split view
camera.aspect = 460 / 480;
camera.updateProjectionMatrix();
threeContainer.appendChild(renderer.domElement);
canvas2D.style.display = 'block';
full3DContainer.style.display = 'none';
}
// Camera position (adjusted for scaled board)
camera.position.set(-3 * scaleFactor, 14 * scaleFactor, 12 * scaleFactor);
camera.lookAt(0, 0, 0);
// Orbit controls for 3D rotation
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI / 2.2;
controls.minDistance = 7.5 * scaleFactor;
controls.maxDistance = 30 * scaleFactor;
controls.enableZoom = false;

// Auto-rotation setup for mode 3
let autoRotate = gameMode === 3;
if (autoRotate) {
controls.autoRotate = true;
controls.autoRotateSpeed = 3.0; // Rotation speed (degrees per second)
}

// Mouse/touch interaction handlers for mode 3
if (gameMode === 3) {
const rendererElement = renderer.domElement;

// Stop auto-rotation on mouse down/touch start
function stopAutoRotation() {
if (autoRotate) {
controls.autoRotate = false;
autoRotate = false;
}
}

// Restart auto-rotation after a delay when interaction stops
function restartAutoRotation() {
if (!autoRotate) {
setTimeout(() => {
if (!controls.enabled || !autoRotate) return;
controls.autoRotate = true;
autoRotate = true;
}, 3000); // Restart after 3 seconds of no interaction
}
}

// Mouse events
rendererElement.addEventListener('mousedown', stopAutoRotation);
rendererElement.addEventListener('mousemove', (event) => {
if (controls.enabled && !autoRotate) {
restartAutoRotation();
}
});
rendererElement.addEventListener('mouseup', restartAutoRotation);

// Touch events for mobile
rendererElement.addEventListener('touchstart', stopAutoRotation);
rendererElement.addEventListener('touchmove', (event) => {
if (controls.enabled && !autoRotate) {
restartAutoRotation();
}
});
rendererElement.addEventListener('touchend', restartAutoRotation);
}

// Create 3D chessboard
const geometry = new THREE.PlaneGeometry(squareSize, squareSize);
const squares = \[\];
const darkBrown = 0x8B4513;
const boardHalf = boardSize * squareSize / 2;
const borderWidth = squareSize / 2;
const borderHalf = borderWidth / 2;
for (let i = 0; i < boardSize; i++) {
for (let j = 0; j < boardSize; j++) {
const color = (i + j) \% 2 === 0 ? 0xffffff : 0x333333;
const material = new THREE.MeshBasicMaterial({ color: color, side: THREE.DoubleSide });
const square = new THREE.Mesh(geometry, material);
square.position.set(i * squareSize - boardHalf + squareSize / 2, 0, j * squareSize - boardHalf + squareSize / 2);
square.rotation.x = Math.PI / 2;
square.userData = { i, j };
scene.add(square);
squares.push(square);
}
}
// Add dark brown border in 3D
const borderMaterial = new THREE.MeshBasicMaterial({ color: darkBrown, side: THREE.DoubleSide });
// Left border
const leftGeo = new THREE.PlaneGeometry(borderWidth, boardSize * squareSize);
const leftBorder = new THREE.Mesh(leftGeo, borderMaterial);
leftBorder.position.set(-(boardHalf + borderHalf), 0, 0);
leftBorder.rotation.x = Math.PI / 2;
scene.add(leftBorder);
// Right border
const rightBorder = new THREE.Mesh(leftGeo.clone(), borderMaterial);
rightBorder.position.set(boardHalf + borderHalf, 0, 0);
rightBorder.rotation.x = Math.PI / 2;
scene.add(rightBorder);
// Front border (positive z)
const frontGeo = new THREE.PlaneGeometry(boardSize * squareSize, borderWidth);
const frontBorder = new THREE.Mesh(frontGeo, borderMaterial);
frontBorder.position.set(0, 0, boardHalf + borderHalf);
frontBorder.rotation.x = Math.PI / 2;
scene.add(frontBorder);
// Back border (negative z)
const backBorder = new THREE.Mesh(frontGeo.clone(), borderMaterial);
backBorder.position.set(0, 0, -(boardHalf + borderHalf));
backBorder.rotation.x = Math.PI / 2;
scene.add(backBorder);
// Add corner borders
const cornerGeo = new THREE.PlaneGeometry(borderWidth, borderWidth);
const corners = \[
{ x: -(boardHalf + borderHalf), z: -(boardHalf + borderHalf) },
{ x: boardHalf + borderHalf, z: -(boardHalf + borderHalf) },
{ x: -(boardHalf + borderHalf), z: boardHalf + borderHalf },
{ x: boardHalf + borderHalf, z: boardHalf + borderHalf }
\];
corners.forEach(corner => {
const cornerMesh = new THREE.Mesh(cornerGeo, borderMaterial);
cornerMesh.position.set(corner.x, 0, corner.z);
cornerMesh.rotation.x = Math.PI / 2;
scene.add(cornerMesh);
});
// Enhanced lighting for cube shading (scaled positions)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.7);
directionalLight.position.set(10 * scaleFactor, 20 * scaleFactor, 10 * scaleFactor);
scene.add(directionalLight);
const pointLight = new THREE.PointLight(0xffffff, 0.5, 100 * scaleFactor);
pointLight.position.set(0, 20 * scaleFactor, 0);
scene.add(pointLight);
// Add labels in 3D with fallback
const fontLoader = new THREE.FontLoader();
fontLoader.load(
'https://threejs.org/examples/fonts/helvetiker_regular.typeface.json',
function (font) {
const lightBrown = 0xD2B48C;
const textMaterial = new THREE.MeshBasicMaterial({ color: lightBrown });
// Numbers 1-N on left side (negative x)
for (let j = 0; j < boardSize; j++) {
const rankNum = (boardSize - j).toString();
const textGeo = new THREE.TextGeometry(rankNum, {
font: font,
size: 0.3 * squareSize,
height: 0.01 * squareSize,
});
textGeo.computeBoundingBox();
const centerOffsetX = -0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x);
const centerOffsetZ = 0.5 * (textGeo.boundingBox.max.y - textGeo.boundingBox.min.y);
const textMesh = new THREE.Mesh(textGeo, textMaterial);
textMesh.position.set(-(boardHalf + borderHalf) + centerOffsetX, 0.01 * squareSize, (j * squareSize - boardHalf + squareSize / 2) + centerOffsetZ);
textMesh.rotation.x = -Math.PI / 2;
scene.add(textMesh);
}
// Letters A-N on front side (positive z)
for (let i = 0; i < boardSize; i++) {
const fileLetter = String.fromCharCode(65 + i);
const textGeo = new THREE.TextGeometry(fileLetter, {
font: font,
size: 0.3 * squareSize,
height: 0.01 * squareSize,
});
textGeo.computeBoundingBox();
const centerOffsetX = -0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x);
const centerOffsetZ = 0.5 * (textGeo.boundingBox.max.y - textGeo.boundingBox.min.y);
const textMesh = new THREE.Mesh(textGeo, textMaterial);
textMesh.position.set((i * squareSize - boardHalf + squareSize / 2) + centerOffsetX, 0.01 * squareSize, boardHalf + borderHalf + centerOffsetZ);
textMesh.rotation.x = -Math.PI / 2;
scene.add(textMesh);
}
},
undefined,
function (error) {
console.warn('Font loading for board labels failed:', error);
}
);
// Raycaster for mouse interaction
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const cubeGeometry = new THREE.BoxGeometry(squareSize, squareSize, squareSize);
const defaultCubeMaterial = new THREE.MeshLambertMaterial({ color: 0xffa500 });
const borderMaterialCube = new THREE.LineBasicMaterial({ color: 0x333333, linewidth: 10 });
const stackNumberMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); // White for stack numbers
const placedCubes = new Map();
const cubeStacks = new Map();
const stackColors = new Map(); // Store random colors for each stack
const stackNumberLabels = new Map(); // Store text meshes for stack numbers
// Function to generate a random color (not too light)
function getRandomColor() {
const maxVal = 180; // Limit RGB values to avoid light colors
const r = Math.floor(Math.random() * maxVal);
const g = Math.floor(Math.random() * maxVal);
const b = Math.floor(Math.random() * maxVal);
return (r << 16) | (g << 8) | b;
}
// Function to update stack number label
function updateStackNumberLabel(posKey, stackHeight, cubeGroup, font) {
if (stacksNumbered === 0 || !font) return;
// Remove existing label if any
if (stackNumberLabels.has(posKey)) {
const oldLabel = stackNumberLabels.get(posKey);
cubeGroup.remove(oldLabel);
stackNumberLabels.delete(posKey);
}
if (stackHeight === 0) return;
// Create new label with bold font and 50\% larger size
const textGeo = new THREE.TextGeometry(stackHeight.toString(), {
font: font,
size: 0.45 * squareSize, // 50\% larger than 0.3
height: 0.01 * squareSize,
});
textGeo.computeBoundingBox();
let centerOffsetX = -0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x);
// Shift "1" 10\% to the left
if (stackHeight === 1) {
centerOffsetX -= 0.1 * squareSize;
}
const centerOffsetZ = 0.5 * (textGeo.boundingBox.max.y - textGeo.boundingBox.min.y);
const textMesh = new THREE.Mesh(textGeo, stackNumberMaterial);
textMesh.position.set(centerOffsetX, squareSize * stackHeight, centerOffsetZ);
textMesh.rotation.x = -Math.PI / 2;
cubeGroup.add(textMesh);
stackNumberLabels.set(posKey, textMesh);
}
let editingEnabled = gameMode === 0; // Disable editing in gameMode 1, 2, and 3
const enteredNumbers = new Map(); // For gameMode 2: user-entered numbers on 2D
// Generate puzzle data for all modes
let selectedSquares = \[\];
let numbers = \[\];
stackColors.clear();
// Step 1: Randomly set the total number of cubes
let targetCubes = Math.floor(Math.random() * (maxNumCubes - minNumCubes + 1)) + minNumCubes;
// Step 2: Randomly set the number of stacks
let numStacks = Math.floor(Math.random() * (maxNumStacks - minNumStacks + 1)) + minNumStacks;
// Adjust targetCubes to respect stack constraints
const minTotal = numStacks * 1;
const maxTotal = numStacks * maxStackHeight;
targetCubes = Math.max(minTotal, Math.min(maxTotal, targetCubes));
// Generate stack positions
let attemptsPos = 0;
let currentCount;
do {
selectedSquares = \[\];
// Start with a random position in the inner board (1 to boardSize-2)
const startI = Math.floor(Math.random() * (boardSize - 2)) + 1;
const startJ = Math.floor(Math.random() * (boardSize - 2)) + 1;
selectedSquares.push(`\${startI},\${startJ}`);
if (stacksRandomColors) stackColors.set(`\${startI},\${startJ}`, getRandomColor());
currentCount = 1;
if (stacksConnected === 1) {
// Connected stacks (original logic)
const directions = \[\[0, 1\], \[1, 0\], \[0, -1\], \[-1, 0\]\];
while (currentCount < numStacks) { // disable_whilecounter_protection
const \[lastI, lastJ\] = selectedSquares\[selectedSquares.length - 1\].split(',').map(Number);
const shuffledDirections = directions.sort(() => Math.random() - 0.5);
let added = false;
for (const \[di, dj\] of shuffledDirections) {
const newI = lastI + di;
const newJ = lastJ + dj;
const pos = `\${newI},\${newJ}`;
if (newI >= 1 && newI < boardSize - 1 && newJ >= 1 && newJ < boardSize - 1 && !selectedSquares.includes(pos)) {
selectedSquares.push(pos);
if (stacksRandomColors) stackColors.set(pos, getRandomColor());
currentCount++;
added = true;
break;
}
}
if (!added) break;
}
} else {
// Non-connected stacks with max 2 spaces gap
const availablePositions = \[\];
for (let i = 1; i < boardSize - 1; i++) {
for (let j = 1; j < boardSize - 1; j++) {
const pos = `\${i},\${j}`;
if (!selectedSquares.includes(pos)) {
availablePositions.push(pos);
}
}
}
while (currentCount < numStacks && availablePositions.length > 0) { // disable_whilecounter_protection
let validPosition = false;
let pos;
let attempts = 0;
const maxAttempts = availablePositions.length * 10;
do {
const idx = Math.floor(Math.random() * availablePositions.length);
pos = availablePositions\[idx\];
const \[newI, newJ\] = pos.split(',').map(Number);
// Check if the new position is within 3 squares (2 empty spaces) of any existing stack
validPosition = selectedSquares.some(square => {
const \[i, j\] = square.split(',').map(Number);
const manhattanDistance = Math.abs(newI - i) + Math.abs(newJ - j);
return manhattanDistance <= 3; // Up to 2 empty spaces
}) || selectedSquares.length === 0; // First position is always valid
if (validPosition) {
selectedSquares.push(pos);
if (stacksRandomColors) stackColors.set(pos, getRandomColor());
availablePositions.splice(idx, 1);
currentCount++;
break;
} else {
availablePositions.splice(idx, 1);
}
attempts++;
} while (availablePositions.length > 0 && attempts < maxAttempts); // disable_whilecounter_protection
if (!validPosition && availablePositions.length === 0) break;
}
}
attemptsPos++;
} while (currentCount < numStacks && attemptsPos < 100); // disable_whilecounter_protection
// Adjust numStacks to actual if fewer
numStacks = selectedSquares.length;
// Step 3: Distribute cubes while respecting maxStackHeight // disable_whilecounter_protection
numbers = new Array(numStacks).fill(0);
let remainingCubes = targetCubes;
// First, assign at least one cube to each stack if possible
for (let i = 0; i < numStacks && remainingCubes > 0; i++) {
numbers\[i\] = 1;
remainingCubes--;
}
// Distribute remaining cubes, respecting maxStackHeight
while (remainingCubes > 0) { // disable_whilecounter_protection
const candidates = \[\];
for (let i = 0; i < numStacks; i++) {
if (numbers\[i\] < maxStackHeight) {
candidates.push(i);
}
}
if (candidates.length === 0) break;
const randIdx = Math.floor(Math.random() * candidates.length);
numbers\[candidates\[randIdx\]\]++;
remainingCubes--;
}
// Verify total cubes and stack heights
const totalCubes = numbers.reduce((sum, h) => sum + h, 0);
if (totalCubes !== targetCubes) {
console.warn(`Generated \${totalCubes} cubes instead of target \${targetCubes}`);
}
numbers.forEach((h, i) => {
if (h > maxStackHeight) {
console.warn(`Stack \${i} has height \${h}, exceeding maxStackHeight \${maxStackHeight}`);
}
});
// Preload font for stack numbers (use bold font)
let fontForStackNumbers = null;
fontLoader.load(
'https://threejs.org/examples/fonts/helvetiker_bold.typeface.json',
function (font) {
fontForStackNumbers = font;
// If gameMode == 1, 2, or 3, pre-place the cubes and add stack number labels
if (gameMode === 1 || gameMode === 2 || gameMode === 3) {
for (let idx = 0; idx < selectedSquares.length; idx++) {
const \[i, j\] = selectedSquares\[idx\].split(',').map(Number);
const posKey = `\${i},\${j}`;
const height = numbers\[idx\];
const cubeGroup = new THREE.Group();
cubeGroup.userData = { i, j };
const cubeColor = stacksRandomColors ? (stackColors.get(posKey) || getRandomColor()) : 0xffa500;
if (stacksRandomColors && !stackColors.has(posKey)) stackColors.set(posKey, cubeColor);
const cubeMaterial = stacksRandomColors ? new THREE.MeshLambertMaterial({ color: cubeColor }) : defaultCubeMaterial;
for (let h = 1; h <= height; h++) {
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
cube.position.set(0, squareSize * (h - 0.5), 0);
cubeGroup.add(cube);
const edges = new THREE.EdgesGeometry(cubeGeometry);
const border = new THREE.LineSegments(edges, borderMaterialCube);
border.position.set(0, squareSize * (h - 0.5), 0);
cubeGroup.add(border);
}
cubeGroup.position.set(
i * squareSize - boardHalf + squareSize / 2,
0,
j * squareSize - boardHalf + squareSize / 2
);
scene.add(cubeGroup);
placedCubes.set(posKey, cubeGroup);
cubeStacks.set(posKey, height);
// Add stack number label if stacksNumbered == 1
if (stacksNumbered === 1 && fontForStackNumbers) {
updateStackNumberLabel(posKey, height, cubeGroup, fontForStackNumbers);
}
}
}
},
undefined,
function (error) {
console.warn('Font loading for stack numbers failed:', error);
}
);
function checkWin() {
try {
if (gameMode === 2 || gameMode === 3) {
let match = enteredNumbers.size === cubeStacks.size;
if (match) {
for (let \[pos, h\] of cubeStacks) {
if (enteredNumbers.get(pos) !== h) {
match = false;
break;
}
}
}
if (match) {
correct_next();
}
return;
}
if (gameMode !== 0) return; // Disable win condition in gameMode 1, 2, and 3 (except above for 2 and 3)
let match = true;
for (let idx = 0; idx < selectedSquares.length; idx++) {
const \[i, j\] = selectedSquares\[idx\].split(',').map(Number);
const posKey = `\${i},\${j}`;
const stackHeight = cubeStacks.get(posKey) || 0;
if (stackHeight !== numbers\[idx\]) {
match = false;
break;
}
}
if (match && selectedSquares.length > 0 && selectedSquares.length === \[...cubeStacks\].filter((\[_, height\]) => height > 0).length) {
correct_next();
editingEnabled = false;
}
} catch (error) {
console.error('Error in checkWin:', error);
}
}
function onDoubleClick(event) {
if (!editingEnabled) return;
event.preventDefault();
try {
const wrapperRect = document.getElementById('gameWrapper').getBoundingClientRect();
const renderWidth = gameMode === 3 ? 400 : 460; // Use original render sizes for mouse coords
mouse.x = ((event.clientX - wrapperRect.left) / renderWidth) * 2 - 1;
mouse.y = -((event.clientY - wrapperRect.top) / 480) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const cubeMeshes = \[\];
placedCubes.forEach(group => {
group.children.forEach(child => {
if (child.isMesh && child.geometry.type === 'BoxGeometry') {
cubeMeshes.push(child);
}
});
});
const cubeIntersects = raycaster.intersectObjects(cubeMeshes);
if (cubeIntersects.length > 0) {
const intersect = cubeIntersects\[0\];
const normal = intersect.face.normal;
const cube = intersect.object;
const group = cube.parent;
const { i, j } = group.userData;
const posKey = `\${i},\${j}`;
let stackHeight = cubeStacks.get(posKey) || 1;
if (normal.y > 0.9) {
if (stackHeight < maxStackHeight) {
stackHeight++;
const cubeMaterial = stacksRandomColors ? new THREE.MeshLambertMaterial({ color: stackColors.get(posKey) }) : defaultCubeMaterial;
const newCube = new THREE.Mesh(cubeGeometry, cubeMaterial);
newCube.position.set(0, squareSize * (stackHeight - 0.5), 0);
group.add(newCube);
const edges = new THREE.EdgesGeometry(cubeGeometry);
const border = new THREE.LineSegments(edges, borderMaterialCube);
border.position.set(0, squareSize * (stackHeight - 0.5), 0);
group.add(border);
cubeStacks.set(posKey, stackHeight);
// Update stack number label
if (stacksNumbered === 1 && fontForStackNumbers) {
updateStackNumberLabel(posKey, stackHeight, group, fontForStackNumbers);
}
}
} else {
if (stackHeight > 0) {
// Remove the topmost cube and its border
const children = group.children;
let removedCube = false;
let removedBorder = false;
// First, remove the text label if it exists
if (stacksNumbered === 1 && stackNumberLabels.has(posKey)) {
const label = stackNumberLabels.get(posKey);
group.remove(label);
stackNumberLabels.delete(posKey);
}
// Remove the topmost border and cube
for (let i = children.length - 1; i >= 0; i--) {
const child = children\[i\];
if (!removedBorder && child.isLineSegments) {
group.remove(child);
removedBorder = true;
} else if (!removedCube && child.isMesh && child.geometry.type === 'BoxGeometry') {
group.remove(child);
removedCube = true;
}
if (removedCube && removedBorder) break;
}
stackHeight--;
if (stackHeight === 0) {
scene.remove(group);
placedCubes.delete(posKey);
cubeStacks.delete(posKey);
// Keep stackColors for 2D view coloring
} else {
cubeStacks.set(posKey, stackHeight);
// Update stack number label
if (stacksNumbered === 1 && fontForStackNumbers) {
updateStackNumberLabel(posKey, stackHeight, group, fontForStackNumbers);
}
}
if (!removedCube || !removedBorder) {
console.warn(`Failed to remove cube or border for stack at \${posKey}. Cube removed: \${removedCube}, Border removed: \${removedBorder}`);
}
}
}
checkWin();
if (gameMode !== 3) draw2DChessboard(); // Redraw 2D board only if in 2D mode
return;
}
const intersects = raycaster.intersectObjects(squares);
if (intersects.length > 0) {
const square = intersects\[0\].object;
const { i, j } = square.userData;
const posKey = `\${i},\${j}`;
if (i >= 1 && i < boardSize - 1 && j >= 1 && j < boardSize - 1 && !placedCubes.has(posKey)) {
const cubeGroup = new THREE.Group();
cubeGroup.userData = { i, j };
const cubeColor = stacksRandomColors ? (stackColors.get(posKey) || getRandomColor()) : 0xffa500;
if (stacksRandomColors && !stackColors.has(posKey)) stackColors.set(posKey, cubeColor);
const cubeMaterial = stacksRandomColors ? new THREE.MeshLambertMaterial({ color: cubeColor }) : defaultCubeMaterial;
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
cube.position.set(0, squareSize / 2, 0);
cubeGroup.add(cube);
const edges = new THREE.EdgesGeometry(cubeGeometry);
const border = new THREE.LineSegments(edges, borderMaterialCube);
border.position.set(0, squareSize / 2, 0);
cubeGroup.add(border);
cubeGroup.position.set(
i * squareSize - boardHalf + squareSize / 2,
0,
j * squareSize - boardHalf + squareSize / 2
);
scene.add(cubeGroup);
placedCubes.set(posKey, cubeGroup);
cubeStacks.set(posKey, 1);
// Add stack number label
if (stacksNumbered === 1 && fontForStackNumbers) {
updateStackNumberLabel(posKey, 1, cubeGroup, fontForStackNumbers);
}
}
checkWin();
if (gameMode !== 3) draw2DChessboard(); // Redraw 2D board only if in 2D mode
}
} catch (error) {
console.error('Error in onDoubleClick:', error);
}
}
renderer.domElement.addEventListener('dblclick', onDoubleClick, false);
// 2D Canvas setup (only for modes 0, 1, 2)
let ctx;
let boardStartX2D, boardStartY2D, square2DSize, borderWidth2D, offsetX2D, offsetY2D;
if (gameMode !== 3) {
ctx = canvas2D.getContext('2d');
canvas2D.width = 460;
canvas2D.height = 480;
square2DSize = Math.min(460 / (boardSize + 2), 480 / (boardSize + 2));
borderWidth2D = square2DSize / 2;
offsetX2D = (460 - (boardSize + 1) * square2DSize) / 2;
offsetY2D = (480 - (boardSize + 1) * square2DSize) / 2;
boardStartX2D = offsetX2D + borderWidth2D;
boardStartY2D = offsetY2D + borderWidth2D;
// Draw 2D chessboard
function draw2DChessboard() {
try {
ctx.clearRect(0, 0, canvas2D.width, canvas2D.height);
// Draw dark brown borders
ctx.fillStyle = '#8B4513';
ctx.fillRect(offsetX2D, offsetY2D, (boardSize + 1) * square2DSize, borderWidth2D);
ctx.fillRect(offsetX2D, offsetY2D + (boardSize + 1) * square2DSize - borderWidth2D, (boardSize + 1) * square2DSize, borderWidth2D);
ctx.fillRect(offsetX2D, offsetY2D, borderWidth2D, (boardSize + 1) * square2DSize);
ctx.fillRect(offsetX2D + (boardSize + 1) * square2DSize - borderWidth2D, offsetY2D, borderWidth2D, (boardSize + 1) * square2DSize);
// Draw board squares
for (let i = 0; i < boardSize; i++) {
for (let j = 0; j < boardSize; j++) {
const pos = `\${i},\${j}`;
const index = selectedSquares.indexOf(pos);
// Color based on selectedSquares, not cubeStacks
if (gameMode !== 2 && gameMode !== 3 && index !== -1 && stacksRandomColors) {
const color = stackColors.get(pos) || 0x333333; // Fallback color
ctx.fillStyle = `#\${color.toString(16).padStart(6, '0')}`;
} else {
ctx.fillStyle = (i + j) \% 2 === 0 ? '#ffffff' : '#333333';
}
ctx.fillRect(boardStartX2D + i * square2DSize, boardStartY2D + j * square2DSize, square2DSize, square2DSize);
// Draw numbers
if (gameMode !== 2 && gameMode !== 3 && index !== -1) {
ctx.fillStyle = stacksRandomColors ? '#ffffff' : ((i + j) \% 2 === 0 ? '#000000' : '#ffffff');
ctx.font = `\${square2DSize / 2}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(numbers\[index\], boardStartX2D + i * square2DSize + square2DSize / 2, boardStartY2D + j * square2DSize + square2DSize / 2);
} else if (gameMode === 2 || gameMode === 3) {
const val = enteredNumbers.get(pos);
if (val > 0) {
ctx.fillStyle = (i + j) \% 2 === 0 ? '#000000' : '#ffffff';
ctx.font = `\${square2DSize / 2}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(val, boardStartX2D + i * square2DSize + square2DSize / 2, boardStartY2D + j * square2DSize + square2DSize / 2);
}
}
}
}
// Draw labels
ctx.font = `\${square2DSize / 3}px Arial`;
ctx.fillStyle = '#D2B48C';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Numbers 1-N on left side
for (let j = 0; j < boardSize; j++) {
const num = boardSize - j;
ctx.fillText(num.toString(), offsetX2D + borderWidth2D / 2, boardStartY2D + j * square2DSize + square2DSize / 2);
}
// Letters A-N on bottom (front) side
for (let i = 0; i < boardSize; i++) {
const letter = String.fromCharCode(65 + i);
ctx.fillText(letter, boardStartX2D + i * square2DSize + square2DSize / 2, offsetY2D + (boardSize + 1) * square2DSize - borderWidth2D / 2);
}
} catch (error) {
console.error('Error in draw2DChessboard:', error);
}
}
draw2DChessboard();
// For gameMode == 2, add click handler for 2D editing
if (gameMode === 2) {
canvas2D.addEventListener('click', function(event) {
try {
const rect = canvas2D.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (x < boardStartX2D || x > boardStartX2D + boardSize * square2DSize || y < boardStartY2D || y > boardStartY2D + boardSize * square2DSize) return;
const i = Math.floor((x - boardStartX2D) / square2DSize);
const j = Math.floor((y - boardStartY2D) / square2DSize);
const posKey = `\${i},\${j}`;
// Create input overlay
const input = document.createElement('input');
input.type = 'text';
input.style.position = 'absolute';
input.style.left = `\${rect.left + boardStartX2D + i * square2DSize}px`;
input.style.top = `\${rect.top + boardStartY2D + j * square2DSize}px`;
input.style.width = `\${square2DSize}px`;
input.style.height = `\${square2DSize}px`;
input.style.textAlign = 'center';
input.style.fontSize = `\${square2DSize / 2}px`;
input.style.border = 'none';
input.style.background = 'transparent';
input.style.color = (i + j) \% 2 === 0 ? '#000000' : '#ffffff';
input.value = enteredNumbers.has(posKey) ? enteredNumbers.get(posKey) : '';
input.maxLength = 1; // Since heights are single digits
document.body.appendChild(input);
input.focus();
// Restrict to numbers
input.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/\[^0-9\]/g, '');
});
// On blur or enter, save and remove
input.addEventListener('blur', () => {
const val = parseInt(input.value) || 0;
if (val === 0) {
enteredNumbers.delete(posKey);
} else {
enteredNumbers.set(posKey, val);
}
input.remove();
draw2DChessboard();
checkWin();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') input.blur();
});
} catch (error) {
console.error('Error in 2D canvas click handler:', error);
}
});
}
}
// Animation loop for 3D
function animate() {
try {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
} catch (error) {
console.error('Error in animation loop:', error);
}
}
animate();
</script>



~;

}

1;

__END__