VoxelGrid and VoxelManager
This commit is contained in:
parent
29d2006e61
commit
1c3411e7de
10 changed files with 1442 additions and 62 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/dist
|
||||||
25
build.js
Normal file
25
build.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { copyFileSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Ensure dist directory exists
|
||||||
|
mkdirSync('dist', { recursive: true });
|
||||||
|
|
||||||
|
// Build JavaScript
|
||||||
|
await build({
|
||||||
|
entryPoints: ['src/game-viewport.js'],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
outfile: 'dist/game-viewport.js',
|
||||||
|
platform: 'browser',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy HTML file
|
||||||
|
copyFileSync('src/index.html', 'dist/index.html');
|
||||||
|
|
||||||
|
console.log('Build complete!');
|
||||||
|
|
||||||
935
package-lock.json
generated
935
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -5,9 +5,10 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "web-dev-server --node-resolve --watch --root-dir src",
|
"build": "node build.js",
|
||||||
"test": "web-test-runner \"test/**/*.test.js\" --node-resolve",
|
"start": "web-dev-server --node-resolve --watch --root-dir dist",
|
||||||
"test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch"
|
"test": "web-test-runner \"test/**/*.test.js\" --node-resolve --puppeteer",
|
||||||
|
"test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch --puppeteer"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
@ -18,11 +19,14 @@
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esm-bundle/chai": "^4.3.4-fix.0",
|
"@esm-bundle/chai": "^4.3.4-fix.0",
|
||||||
"@web/test-runner": "^0.20.2",
|
|
||||||
"@web/dev-server": "^0.4.6",
|
"@web/dev-server": "^0.4.6",
|
||||||
|
"@web/test-runner": "^0.20.2",
|
||||||
|
"@web/test-runner-puppeteer": "^0.18.0",
|
||||||
|
"esbuild": "^0.27.1",
|
||||||
"sinon": "^21.0.0"
|
"sinon": "^21.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lit": "^3.3.1",
|
||||||
"three": "^0.182.0"
|
"three": "^0.182.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import {
|
import { LitElement, html, css } from "lit";
|
||||||
LitElement,
|
import * as THREE from "three";
|
||||||
html,
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
css,
|
import { VoxelGrid } from "./grid/VoxelGrid.js";
|
||||||
} from "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js";
|
import { VoxelManager } from "./grid/VoxelManager.js";
|
||||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js";
|
|
||||||
|
|
||||||
// --- 1. Define the Game Viewport Component using Lit ---
|
export class GameViewport extends LitElement {
|
||||||
class GameViewport extends LitElement {
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -14,66 +12,114 @@ class GameViewport extends LitElement {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
canvas {
|
#canvas-container {
|
||||||
display: block;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.scene = null;
|
||||||
|
this.camera = null;
|
||||||
|
this.renderer = null;
|
||||||
|
this.voxelGrid = null;
|
||||||
|
this.voxelManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
firstUpdated() {
|
firstUpdated() {
|
||||||
// Initialize Three.js scene after the component renders
|
|
||||||
this.initThreeJS();
|
this.initThreeJS();
|
||||||
|
this.initGameWorld();
|
||||||
|
this.animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
initThreeJS() {
|
initThreeJS() {
|
||||||
const container = this.shadowRoot.getElementById("canvas-container");
|
const container = this.shadowRoot.getElementById("canvas-container");
|
||||||
const scene = new THREE.Scene();
|
|
||||||
scene.background = new THREE.Color(0x0a0b10); // Void Black
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(
|
// Scene Setup
|
||||||
75,
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = new THREE.Color(0x0a0b10);
|
||||||
|
|
||||||
|
// Lighting (Essential for LambertMaterial)
|
||||||
|
const ambientLight = new THREE.AmbientLight(0x404040, 1.5); // Soft white light
|
||||||
|
this.scene.add(ambientLight);
|
||||||
|
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
|
||||||
|
dirLight.position.set(10, 20, 10);
|
||||||
|
this.scene.add(dirLight);
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
this.camera = new THREE.PerspectiveCamera(
|
||||||
|
45,
|
||||||
window.innerWidth / window.innerHeight,
|
window.innerWidth / window.innerHeight,
|
||||||
0.1,
|
0.1,
|
||||||
1000
|
1000
|
||||||
);
|
);
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true }); // Enable AA for smoother edges
|
this.camera.position.set(20, 20, 20);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
// Set initial size
|
// Renderer
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
container.appendChild(renderer.domElement);
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
container.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
// Add a rotating Voxel (Cube)
|
// Controls
|
||||||
const geometry = new THREE.BoxGeometry();
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||||
const material = new THREE.MeshBasicMaterial({
|
this.controls.enableDamping = true;
|
||||||
color: 0x00f0ff,
|
|
||||||
wireframe: true,
|
|
||||||
});
|
|
||||||
const cube = new THREE.Mesh(geometry, material);
|
|
||||||
scene.add(cube);
|
|
||||||
|
|
||||||
camera.position.z = 5;
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
cube.rotation.x += 0.01;
|
|
||||||
cube.rotation.y += 0.01;
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
};
|
|
||||||
animate();
|
|
||||||
|
|
||||||
// Handle Resize
|
// Handle Resize
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", this.onWindowResize.bind(this));
|
||||||
camera.aspect = window.innerWidth / window.innerHeight;
|
}
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
initGameWorld() {
|
||||||
});
|
// 1. Create Data Grid
|
||||||
|
this.voxelGrid = new VoxelGrid(16, 8, 16);
|
||||||
|
|
||||||
|
// 2. Generate Test Terrain (Simple Flat Floor + Random Pillars)
|
||||||
|
for (let x = 0; x < 16; x++) {
|
||||||
|
for (let z = 0; z < 16; z++) {
|
||||||
|
// Base Floor (Stone)
|
||||||
|
this.voxelGrid.setVoxel(x, 0, z, 1);
|
||||||
|
|
||||||
|
// Random Details (Dirt/Grass)
|
||||||
|
if (Math.random() > 0.8) {
|
||||||
|
this.voxelGrid.setVoxel(x, 1, z, 2); // Dirt Mound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aether Crystal Pillar
|
||||||
|
if (x === 8 && z === 8) {
|
||||||
|
this.voxelGrid.setVoxel(x, 1, z, 4);
|
||||||
|
this.voxelGrid.setVoxel(x, 2, z, 4);
|
||||||
|
this.voxelGrid.setVoxel(x, 3, z, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Initialize Visual Manager
|
||||||
|
this.voxelManager = new VoxelManager(this.voxelGrid, this.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
animate() {
|
||||||
|
requestAnimationFrame(this.animate.bind(this));
|
||||||
|
|
||||||
|
this.controls.update();
|
||||||
|
|
||||||
|
// Update Voxels if dirty
|
||||||
|
if (this.voxelManager) this.voxelManager.update();
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowResize() {
|
||||||
|
if (!this.camera || !this.renderer) return;
|
||||||
|
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html` <div id="canvas-container"></div> `;
|
return html`<div id="canvas-container"></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the web component
|
|
||||||
customElements.define("game-viewport", GameViewport);
|
customElements.define("game-viewport", GameViewport);
|
||||||
|
|
|
||||||
72
src/grid/VoxelGrid.js
Normal file
72
src/grid/VoxelGrid.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* VoxelGrid.js
|
||||||
|
* The pure data representation of the game world.
|
||||||
|
* Uses a flat Uint8Array for high-performance memory access.
|
||||||
|
*/
|
||||||
|
export class VoxelGrid {
|
||||||
|
constructor(width, height, depth) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.depth = depth;
|
||||||
|
|
||||||
|
// 0 = Air, 1+ = Solid Blocks
|
||||||
|
this.cells = new Uint8Array(width * height * depth);
|
||||||
|
this.dirty = true; // Flag to tell Renderer to update
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts 3D coordinates to a flat array index.
|
||||||
|
*/
|
||||||
|
getIndex(x, y, z) {
|
||||||
|
return y * this.width * this.depth + z * this.width + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if coordinates are inside the grid dimensions.
|
||||||
|
*/
|
||||||
|
isValidBounds(x, y, z) {
|
||||||
|
return (
|
||||||
|
x >= 0 &&
|
||||||
|
x < this.width &&
|
||||||
|
y >= 0 &&
|
||||||
|
y < this.height &&
|
||||||
|
z >= 0 &&
|
||||||
|
z < this.depth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a voxel ID at the specified position.
|
||||||
|
* @param {number} x
|
||||||
|
* @param {number} y
|
||||||
|
* @param {number} z
|
||||||
|
* @param {number} typeId - 0 for Air, integers for material types
|
||||||
|
*/
|
||||||
|
setVoxel(x, y, z, typeId) {
|
||||||
|
if (!this.isValidBounds(x, y, z)) return;
|
||||||
|
|
||||||
|
const index = this.getIndex(x, y, z);
|
||||||
|
|
||||||
|
// Only update if changed to save render cycles
|
||||||
|
if (this.cells[index] !== typeId) {
|
||||||
|
this.cells[index] = typeId;
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the voxel ID at the specified position.
|
||||||
|
* Returns 0 (Air) if out of bounds.
|
||||||
|
*/
|
||||||
|
getVoxel(x, y, z) {
|
||||||
|
if (!this.isValidBounds(x, y, z)) return 0;
|
||||||
|
return this.cells[this.getIndex(x, y, z)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Checks if a voxel is solid (non-air).
|
||||||
|
*/
|
||||||
|
isSolid(x, y, z) {
|
||||||
|
return this.getVoxel(x, y, z) !== 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/grid/VoxelManager.js
Normal file
92
src/grid/VoxelManager.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoxelManager.js
|
||||||
|
* Handles the Three.js rendering of the VoxelGrid.
|
||||||
|
* Uses InstancedMesh for performance (1 draw call for 10,000 blocks).
|
||||||
|
*/
|
||||||
|
export class VoxelManager {
|
||||||
|
constructor(grid, scene) {
|
||||||
|
this.grid = grid;
|
||||||
|
this.scene = scene;
|
||||||
|
this.mesh = null;
|
||||||
|
|
||||||
|
// Define Material Palette (ID -> Color)
|
||||||
|
this.palette = [
|
||||||
|
null, // 0: Air (Invisible)
|
||||||
|
new THREE.Color(0x888888), // 1: Stone (Grey)
|
||||||
|
new THREE.Color(0x8b4513), // 2: Dirt (Brown)
|
||||||
|
new THREE.Color(0x228b22), // 3: Grass (Green)
|
||||||
|
new THREE.Color(0x00f0ff), // 4: Aether Crystal (Cyan)
|
||||||
|
];
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Create geometry once
|
||||||
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
|
||||||
|
// Basic material allows for coloring individual instances
|
||||||
|
const material = new THREE.MeshLambertMaterial({ color: 0xffffff });
|
||||||
|
|
||||||
|
// Calculate max capacity based on grid size
|
||||||
|
const count = this.grid.width * this.grid.height * this.grid.depth;
|
||||||
|
|
||||||
|
// Create the InstancedMesh
|
||||||
|
this.mesh = new THREE.InstancedMesh(geometry, material, count);
|
||||||
|
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
|
||||||
|
// Add to scene
|
||||||
|
this.scene.add(this.mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the visual mesh based on the grid data.
|
||||||
|
* Call this in the game loop if grid.dirty is true.
|
||||||
|
*/
|
||||||
|
update() {
|
||||||
|
if (!this.grid.dirty) return;
|
||||||
|
|
||||||
|
let instanceId = 0;
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
|
||||||
|
for (let y = 0; y < this.grid.height; y++) {
|
||||||
|
for (let z = 0; z < this.grid.depth; z++) {
|
||||||
|
for (let x = 0; x < this.grid.width; x++) {
|
||||||
|
const typeId = this.grid.getVoxel(x, y, z);
|
||||||
|
|
||||||
|
if (typeId !== 0) {
|
||||||
|
// Position the dummy object
|
||||||
|
dummy.position.set(x, y, z);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
|
||||||
|
// Update the instance matrix
|
||||||
|
this.mesh.setMatrixAt(instanceId, dummy.matrix);
|
||||||
|
|
||||||
|
// Update the instance color based on ID
|
||||||
|
const color = this.palette[typeId] || new THREE.Color(0xff00ff); // Magenta = Error
|
||||||
|
this.mesh.setColorAt(instanceId, color);
|
||||||
|
|
||||||
|
instanceId++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide unused instances by scaling them to zero (or moving them to infinity)
|
||||||
|
// For simplicity in this prototype, we define count as max possible,
|
||||||
|
// but in production, we would manage the count property more dynamically.
|
||||||
|
this.mesh.count = instanceId;
|
||||||
|
|
||||||
|
this.mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
|
||||||
|
// instanceColor is lazy-created by Three.js only when setColorAt is called.
|
||||||
|
// If the grid is empty, instanceColor might be null.
|
||||||
|
if (this.mesh.instanceColor) {
|
||||||
|
this.mesh.instanceColor.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.grid.dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,17 +13,6 @@
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- IMPORT MAP: Defines module locations -->
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
|
||||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
|
|
||||||
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
/* Palette Definition */
|
/* Palette Definition */
|
||||||
|
|
|
||||||
96
test/grid/VoxelGrid.test.js
Normal file
96
test/grid/VoxelGrid.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||||
|
|
||||||
|
describe("Phase 1: VoxelGrid Data Structure", () => {
|
||||||
|
let grid;
|
||||||
|
const width = 10;
|
||||||
|
const height = 10;
|
||||||
|
const depth = 10;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new VoxelGrid(width, height, depth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize with correct dimensions and empty cells", () => {
|
||||||
|
expect(grid.width).to.equal(width);
|
||||||
|
expect(grid.height).to.equal(height);
|
||||||
|
expect(grid.depth).to.equal(depth);
|
||||||
|
expect(grid.cells).to.be.instanceOf(Uint8Array);
|
||||||
|
expect(grid.cells.length).to.equal(width * height * depth);
|
||||||
|
|
||||||
|
// Verify all are 0 (Air)
|
||||||
|
const isAllZero = grid.cells.every((val) => val === 0);
|
||||||
|
expect(isAllZero).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: Should correctly validate bounds", () => {
|
||||||
|
// Inside bounds
|
||||||
|
expect(grid.isValidBounds(0, 0, 0)).to.be.true;
|
||||||
|
expect(grid.isValidBounds(5, 5, 5)).to.be.true;
|
||||||
|
expect(grid.isValidBounds(width - 1, height - 1, depth - 1)).to.be.true;
|
||||||
|
|
||||||
|
// Outside bounds (Negative)
|
||||||
|
expect(grid.isValidBounds(-1, 0, 0)).to.be.false;
|
||||||
|
expect(grid.isValidBounds(0, -1, 0)).to.be.false;
|
||||||
|
expect(grid.isValidBounds(0, 0, -1)).to.be.false;
|
||||||
|
|
||||||
|
// Outside bounds (Overflow)
|
||||||
|
expect(grid.isValidBounds(width, 0, 0)).to.be.false;
|
||||||
|
expect(grid.isValidBounds(0, height, 0)).to.be.false;
|
||||||
|
expect(grid.isValidBounds(0, 0, depth)).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: Should store and retrieve voxel IDs", () => {
|
||||||
|
const x = 2,
|
||||||
|
y = 3,
|
||||||
|
z = 4;
|
||||||
|
const typeId = 5; // Arbitrary material ID
|
||||||
|
|
||||||
|
grid.setVoxel(x, y, z, typeId);
|
||||||
|
|
||||||
|
const result = grid.getVoxel(x, y, z);
|
||||||
|
expect(result).to.equal(typeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: Should return 0 (Air) for out-of-bounds getVoxel", () => {
|
||||||
|
expect(grid.getVoxel(-5, 0, 0)).to.equal(0);
|
||||||
|
expect(grid.getVoxel(100, 100, 100)).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: Should handle isSolid checks", () => {
|
||||||
|
grid.setVoxel(1, 1, 1, 1); // Solid
|
||||||
|
grid.setVoxel(2, 2, 2, 0); // Air
|
||||||
|
|
||||||
|
expect(grid.isSolid(1, 1, 1)).to.be.true;
|
||||||
|
expect(grid.isSolid(2, 2, 2)).to.be.false;
|
||||||
|
expect(grid.isSolid(-1, -1, -1)).to.be.false; // Out of bounds is not solid
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: Should manage the dirty flag correctly", () => {
|
||||||
|
// Initial state
|
||||||
|
expect(grid.dirty).to.be.true;
|
||||||
|
|
||||||
|
// Reset flag manually (simulating a render cycle completion)
|
||||||
|
grid.dirty = false;
|
||||||
|
|
||||||
|
// Set voxel to SAME value
|
||||||
|
grid.setVoxel(0, 0, 0, 0);
|
||||||
|
expect(grid.dirty).to.be.false; // Should not dirty if value didn't change
|
||||||
|
|
||||||
|
// Set voxel to NEW value
|
||||||
|
grid.setVoxel(0, 0, 0, 1);
|
||||||
|
expect(grid.dirty).to.be.true; // Should be dirty now
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: Should calculate flat array index correctly", () => {
|
||||||
|
// Based on logic: (y * width * depth) + (z * width) + x
|
||||||
|
// x=1, y=0, z=0 -> 1
|
||||||
|
expect(grid.getIndex(1, 0, 0)).to.equal(1);
|
||||||
|
|
||||||
|
// x=0, y=0, z=1 -> 1 * 10 = 10
|
||||||
|
expect(grid.getIndex(0, 0, 1)).to.equal(10);
|
||||||
|
|
||||||
|
// x=0, y=1, z=0 -> 1 * 10 * 10 = 100
|
||||||
|
expect(grid.getIndex(0, 1, 0)).to.equal(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
test/grid/VoxelManager.test.js
Normal file
124
test/grid/VoxelManager.test.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import sinon from "sinon";
|
||||||
|
import { VoxelManager } from "../../src/grid/VoxelManager.js";
|
||||||
|
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||||
|
|
||||||
|
describe("Phase 1: VoxelManager (Renderer)", () => {
|
||||||
|
let grid;
|
||||||
|
let scene;
|
||||||
|
let manager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup a basic scene and grid
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
grid = new VoxelGrid(4, 4, 4); // Small 4x4x4 grid
|
||||||
|
manager = new VoxelManager(grid, scene);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize and add InstancedMesh to the scene", () => {
|
||||||
|
// Check if something was added to the scene
|
||||||
|
expect(scene.children.length).to.equal(1);
|
||||||
|
|
||||||
|
const mesh = scene.children[0];
|
||||||
|
expect(mesh).to.be.instanceOf(THREE.InstancedMesh);
|
||||||
|
|
||||||
|
// Max capacity should match grid size
|
||||||
|
expect(mesh.count).to.equal(4 * 4 * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: Should update visual instances based on grid data", () => {
|
||||||
|
// 1. Setup Data: Place 3 voxels
|
||||||
|
grid.setVoxel(0, 0, 0, 1); // Stone
|
||||||
|
grid.setVoxel(1, 0, 0, 2); // Dirt
|
||||||
|
grid.setVoxel(0, 1, 0, 3); // Grass
|
||||||
|
|
||||||
|
// 2. Act: Trigger Update
|
||||||
|
// (VoxelGrid sets dirty=true automatically on setVoxel)
|
||||||
|
manager.update();
|
||||||
|
|
||||||
|
// 3. Assert: Mesh count should represent only ACTIVE voxels
|
||||||
|
// Note: In the implementation, we update `mesh.count` to the number of visible instances
|
||||||
|
const mesh = manager.mesh;
|
||||||
|
expect(mesh.count).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: Should respect the Dirty Flag (Performance)", () => {
|
||||||
|
// Setup: Add a voxel so instanceColor buffer is initialized by Three.js
|
||||||
|
grid.setVoxel(0, 0, 0, 1);
|
||||||
|
|
||||||
|
const mesh = manager.mesh;
|
||||||
|
|
||||||
|
// 1. First Update (Dirty is true by default)
|
||||||
|
manager.update();
|
||||||
|
expect(grid.dirty).to.be.false;
|
||||||
|
|
||||||
|
// Capture current versions. Three.js increments these when needsUpdate = true.
|
||||||
|
const matrixVersion = mesh.instanceMatrix.version;
|
||||||
|
// instanceColor might be null if no color set logic ran, but manager.update() ensures it if voxels exist.
|
||||||
|
const colorVersion = mesh.instanceColor ? mesh.instanceColor.version : -1;
|
||||||
|
|
||||||
|
// 2. Call Update again (Dirty is false)
|
||||||
|
manager.update();
|
||||||
|
|
||||||
|
// 3. Assert: Versions should NOT have incremented
|
||||||
|
expect(mesh.instanceMatrix.version).to.equal(matrixVersion);
|
||||||
|
if (mesh.instanceColor) {
|
||||||
|
expect(mesh.instanceColor.version).to.equal(colorVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Change data -> Dirty becomes true
|
||||||
|
grid.setVoxel(3, 3, 3, 1);
|
||||||
|
expect(grid.dirty).to.be.true;
|
||||||
|
|
||||||
|
// 5. Update again
|
||||||
|
manager.update();
|
||||||
|
|
||||||
|
// 6. Assert: Versions SHOULD increment
|
||||||
|
expect(mesh.instanceMatrix.version).to.be.greaterThan(matrixVersion);
|
||||||
|
if (mesh.instanceColor) {
|
||||||
|
expect(mesh.instanceColor.version).to.be.greaterThan(colorVersion);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: Should apply correct colors from palette", () => {
|
||||||
|
// ID 4 is Cyan (Aether Crystal) based on Manager code
|
||||||
|
grid.setVoxel(0, 0, 0, 4);
|
||||||
|
manager.update();
|
||||||
|
|
||||||
|
const mesh = manager.mesh;
|
||||||
|
const color = new THREE.Color();
|
||||||
|
|
||||||
|
// Get color of the first instance (index 0)
|
||||||
|
mesh.getColorAt(0, color);
|
||||||
|
|
||||||
|
// Check against the palette definition in VoxelManager
|
||||||
|
const expectedColor = new THREE.Color(0x00f0ff); // Cyan
|
||||||
|
|
||||||
|
expect(color.r).to.be.closeTo(expectedColor.r, 0.01);
|
||||||
|
expect(color.g).to.be.closeTo(expectedColor.g, 0.01);
|
||||||
|
expect(color.b).to.be.closeTo(expectedColor.b, 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: Should position instances correctly in 3D space", () => {
|
||||||
|
const targetX = 2;
|
||||||
|
const targetY = 3;
|
||||||
|
const targetZ = 1;
|
||||||
|
|
||||||
|
grid.setVoxel(targetX, targetY, targetZ, 1);
|
||||||
|
manager.update();
|
||||||
|
|
||||||
|
const mesh = manager.mesh;
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
|
||||||
|
// Get matrix of first instance
|
||||||
|
mesh.getMatrixAt(0, matrix);
|
||||||
|
|
||||||
|
const position = new THREE.Vector3();
|
||||||
|
position.setFromMatrixPosition(matrix);
|
||||||
|
|
||||||
|
expect(position.x).to.equal(targetX);
|
||||||
|
expect(position.y).to.equal(targetY);
|
||||||
|
expect(position.z).to.equal(targetZ);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue