VoxelGrid and VoxelManager

This commit is contained in:
Matthew Mone 2025-12-16 15:52:58 -08:00
parent 29d2006e61
commit 1c3411e7de
10 changed files with 1442 additions and 62 deletions

3
.gitignore vendored
View file

@ -1 +1,2 @@
/node_modules
/node_modules
/dist

25
build.js Normal file
View 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

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,10 @@
"type": "module",
"main": "index.js",
"scripts": {
"start": "web-dev-server --node-resolve --watch --root-dir src",
"test": "web-test-runner \"test/**/*.test.js\" --node-resolve",
"test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch"
"build": "node build.js",
"start": "web-dev-server --node-resolve --watch --root-dir dist",
"test": "web-test-runner \"test/**/*.test.js\" --node-resolve --puppeteer",
"test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch --puppeteer"
},
"repository": {
"type": "git",
@ -18,11 +19,14 @@
"license": "ISC",
"devDependencies": {
"@esm-bundle/chai": "^4.3.4-fix.0",
"@web/test-runner": "^0.20.2",
"@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"
},
"dependencies": {
"lit": "^3.3.1",
"three": "^0.182.0"
}
}

View file

@ -1,12 +1,10 @@
import {
LitElement,
html,
css,
} from "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js";
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js";
import { LitElement, html, css } from "lit";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { VoxelGrid } from "./grid/VoxelGrid.js";
import { VoxelManager } from "./grid/VoxelManager.js";
// --- 1. Define the Game Viewport Component using Lit ---
class GameViewport extends LitElement {
export class GameViewport extends LitElement {
static styles = css`
:host {
display: block;
@ -14,66 +12,114 @@ class GameViewport extends LitElement {
height: 100%;
overflow: hidden;
}
canvas {
display: block;
#canvas-container {
width: 100%;
height: 100%;
}
`;
constructor() {
super();
this.scene = null;
this.camera = null;
this.renderer = null;
this.voxelGrid = null;
this.voxelManager = null;
}
firstUpdated() {
// Initialize Three.js scene after the component renders
this.initThreeJS();
this.initGameWorld();
this.animate();
}
initThreeJS() {
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(
75,
// Scene Setup
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,
0.1,
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.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(this.renderer.domElement);
// Add a rotating Voxel (Cube)
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({
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();
// Controls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
// Handle Resize
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
window.addEventListener("resize", this.onWindowResize.bind(this));
}
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() {
return html` <div id="canvas-container"></div> `;
return html`<div id="canvas-container"></div>`;
}
}
// Register the web component
customElements.define("game-viewport", GameViewport);

72
src/grid/VoxelGrid.js Normal file
View 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
View 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;
}
}

View file

@ -13,17 +13,6 @@
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>
:root {
/* Palette Definition */

View 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);
});
});

View 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);
});
});