updated generation
This commit is contained in:
parent
1c3411e7de
commit
4f7550a8e9
16 changed files with 859 additions and 305 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
/node_modules
|
||||
/dist
|
||||
/dist
|
||||
/coverage
|
||||
24
build.js
24
build.js
|
|
@ -1,25 +1,25 @@
|
|||
import { build } from 'esbuild';
|
||||
import { copyFileSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
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 });
|
||||
mkdirSync("dist", { recursive: true });
|
||||
|
||||
// Build JavaScript
|
||||
await build({
|
||||
entryPoints: ['src/game-viewport.js'],
|
||||
entryPoints: ["src/game-viewport.js"],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
outfile: 'dist/game-viewport.js',
|
||||
platform: 'browser',
|
||||
format: "esm",
|
||||
outfile: "dist/game-viewport.js",
|
||||
sourcemap: true,
|
||||
platform: "browser",
|
||||
});
|
||||
|
||||
// Copy HTML file
|
||||
copyFileSync('src/index.html', 'dist/index.html');
|
||||
|
||||
console.log('Build complete!');
|
||||
copyFileSync("src/index.html", "dist/index.html");
|
||||
|
||||
console.log("Build complete!");
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
"scripts": {
|
||||
"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"
|
||||
"test": "web-test-runner \"test/**/*.test.js\" --node-resolve",
|
||||
"test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch --config web-test-runner.config.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ export class GameViewport extends LitElement {
|
|||
this.voxelManager = null;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
async firstUpdated() {
|
||||
this.initThreeJS();
|
||||
this.initGameWorld();
|
||||
await this.initGameWorld();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
|
|
@ -71,32 +71,20 @@ export class GameViewport extends LitElement {
|
|||
window.addEventListener("resize", this.onWindowResize.bind(this));
|
||||
}
|
||||
|
||||
initGameWorld() {
|
||||
async initGameWorld() {
|
||||
// 1. Create Data Grid
|
||||
this.voxelGrid = new VoxelGrid(16, 8, 16);
|
||||
this.voxelGrid = new VoxelGrid(30, 8, 30);
|
||||
|
||||
// 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);
|
||||
const { CaveGenerator } = await import("./generation/CaveGenerator.js");
|
||||
const { RuinGenerator } = await import("./generation/RuinGenerator.js");
|
||||
// const ruinGen = new RuinGenerator(this.voxelGrid, 12345);
|
||||
// ruinGen.generate(3, 4, 6);
|
||||
|
||||
// Random Details (Dirt/Grass)
|
||||
if (Math.random() > 0.8) {
|
||||
this.voxelGrid.setVoxel(x, 1, z, 2); // Dirt Mound
|
||||
}
|
||||
const caveGen = new CaveGenerator(this.voxelGrid, 12345);
|
||||
caveGen.generate(0.5, 1);
|
||||
|
||||
// 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);
|
||||
this.voxelManager.init();
|
||||
}
|
||||
|
||||
animate() {
|
||||
|
|
|
|||
59
src/generation/BaseGenerator.js
Normal file
59
src/generation/BaseGenerator.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { SeededRandom } from "../utils/SeededRandom.js";
|
||||
|
||||
export class BaseGenerator {
|
||||
constructor(grid, seed) {
|
||||
this.grid = grid;
|
||||
this.rng = new SeededRandom(seed);
|
||||
this.width = grid.size.x;
|
||||
this.height = grid.size.y;
|
||||
this.depth = grid.size.z;
|
||||
}
|
||||
|
||||
getSolidNeighbors(x, y, z) {
|
||||
let count = 0;
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
for (let j = -1; j <= 1; j++) {
|
||||
for (let k = -1; k <= 1; k++) {
|
||||
if (i === 0 && j === 0 && k === 0) continue;
|
||||
if (
|
||||
!this.grid.isValidBounds(x + i, y + j, z + k) ||
|
||||
this.grid.isSolid({ x: x + i, y: y + j, z: z + k })
|
||||
) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreads destructible objects across valid floor tiles.
|
||||
* @param {number} objectId - The Voxel ID for the cover (e.g., 10 for Wood).
|
||||
* @param {number} density - 0.0 to 1.0 (e.g., 0.1 for 10% coverage).
|
||||
*/
|
||||
scatterCover(objectId, density) {
|
||||
const validSpots = [];
|
||||
|
||||
// 1. Identify all valid "Floor" surfaces (Solid below, Air current)
|
||||
for (let x = 1; x < this.width - 1; x++) {
|
||||
for (let z = 1; z < this.depth - 1; z++) {
|
||||
for (let y = 1; y < this.height - 1; y++) {
|
||||
const currentId = this.grid.getCell(x, y, z);
|
||||
const belowId = this.grid.getCell(x, y - 1, z);
|
||||
|
||||
if (currentId === 0 && belowId !== 0) {
|
||||
validSpots.push({ x, y, z });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Place objects based on density
|
||||
for (const pos of validSpots) {
|
||||
if (this.rng.chance(density)) {
|
||||
this.grid.setCell(pos.x, pos.y, pos.z, objectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/generation/CaveGenerator.js
Normal file
52
src/generation/CaveGenerator.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { BaseGenerator } from "./BaseGenerator.js";
|
||||
|
||||
export class CaveGenerator extends BaseGenerator {
|
||||
generate(fillPercent = 0.45, iterations = 4) {
|
||||
// 1. Initial Noise
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
for (let z = 0; z < this.depth; z++) {
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
// Force the top layer to be Air (No Ceiling) for top-down visibility
|
||||
if (y >= this.height - 1) {
|
||||
this.grid.setCell(x, y, z, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Edges/Bottom are always solid container
|
||||
if (
|
||||
x === 0 ||
|
||||
z === 0 ||
|
||||
x === this.width - 1 ||
|
||||
z === this.depth - 1 ||
|
||||
y === 0
|
||||
) {
|
||||
this.grid.setCell(x, y, z, 1);
|
||||
} else {
|
||||
const isSolid = this.rng.chance(fillPercent);
|
||||
this.grid.setCell(x, y, z, isSolid ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Smoothing Iterations
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
this.smooth();
|
||||
}
|
||||
}
|
||||
|
||||
smooth() {
|
||||
const nextGrid = this.grid.clone();
|
||||
for (let x = 1; x < this.width - 1; x++) {
|
||||
for (let z = 1; z < this.depth - 1; z++) {
|
||||
for (let y = 1; y < this.height - 1; y++) {
|
||||
const neighbors = this.getSolidNeighbors(x, y, z);
|
||||
// Standard automata rules
|
||||
if (neighbors > 13) nextGrid.setCell(x, y, z, 1);
|
||||
else if (neighbors < 13) nextGrid.setCell(x, y, z, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.grid.cells = nextGrid.cells;
|
||||
}
|
||||
}
|
||||
66
src/generation/PostProcessing.js
Normal file
66
src/generation/PostProcessing.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export class PostProcessor {
|
||||
static ensureConnectivity(grid) {
|
||||
// 1. Identify all empty (Air) regions
|
||||
const regions = [];
|
||||
const visited = new Set();
|
||||
|
||||
for (let x = 0; x < grid.size.x; x++) {
|
||||
for (let z = 0; z < grid.size.z; z++) {
|
||||
// We only care about "Floor" tiles (Air with Solid below)
|
||||
// because that's where units stand
|
||||
if (grid.getCell(x, 1, z) === 0 && grid.getCell(x, 0, z) !== 0) {
|
||||
const key = `${x},1,${z}`;
|
||||
if (!visited.has(key)) {
|
||||
const region = this.floodFill(grid, x, 1, z, visited);
|
||||
regions.push(region);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (regions.length === 0) return;
|
||||
|
||||
// 2. Sort by size (largest first)
|
||||
regions.sort((a, b) => b.length - a.length);
|
||||
|
||||
// 3. Fill all smaller regions with stone (or connect them later)
|
||||
// For prototype, we just fill them to avoid soft-locks
|
||||
for (let i = 1; i < regions.length; i++) {
|
||||
for (const pos of regions[i]) {
|
||||
grid.setCell(pos.x, pos.y, pos.z, 1); // Fill air with stone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static floodFill(grid, startX, startY, startZ, visitedGlobal) {
|
||||
const region = [];
|
||||
const stack = [{ x: startX, y: startY, z: startZ }];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { x, y, z } = stack.pop();
|
||||
const key = `${x},${y},${z}`;
|
||||
|
||||
if (visitedGlobal.has(key)) continue;
|
||||
visitedGlobal.add(key);
|
||||
region.push({ x, y, z });
|
||||
|
||||
// Check neighbors (Cardinal)
|
||||
const neighbors = [
|
||||
{ x: x + 1, y, z },
|
||||
{ x: x - 1, y, z },
|
||||
{ x, y, z: z + 1 },
|
||||
{ x, y, z: z - 1 },
|
||||
];
|
||||
|
||||
for (const n of neighbors) {
|
||||
if (grid.isValidBounds(n.x, n.y, n.z)) {
|
||||
// Is it also an Air tile?
|
||||
if (grid.getCell(n.x, n.y, n.z) === 0) {
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return region;
|
||||
}
|
||||
}
|
||||
105
src/generation/RuinGenerator.js
Normal file
105
src/generation/RuinGenerator.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { BaseGenerator } from "./BaseGenerator.js";
|
||||
|
||||
export class RuinGenerator extends BaseGenerator {
|
||||
generate(roomCount = 5, minSize = 4, maxSize = 8) {
|
||||
// Start with Empty Air (0), not Solid Stone
|
||||
this.grid.fill(0);
|
||||
|
||||
const rooms = [];
|
||||
|
||||
// 1. Place Rooms
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (rooms.length >= roomCount) break;
|
||||
|
||||
const w = this.rng.rangeInt(minSize, maxSize);
|
||||
const d = this.rng.rangeInt(minSize, maxSize);
|
||||
const x = this.rng.rangeInt(1, this.width - w - 2);
|
||||
const z = this.rng.rangeInt(1, this.depth - d - 2);
|
||||
const y = 1; // Main floor level
|
||||
|
||||
const room = { x, y, z, w, d };
|
||||
|
||||
if (!this.roomsOverlap(room, rooms)) {
|
||||
this.buildRoom(room); // Additive building
|
||||
rooms.push(room);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Connect Rooms
|
||||
for (let i = 1; i < rooms.length; i++) {
|
||||
const prev = this.getCenter(rooms[i - 1]);
|
||||
const curr = this.getCenter(rooms[i]);
|
||||
this.buildCorridor(prev, curr); // Additive building
|
||||
}
|
||||
}
|
||||
|
||||
roomsOverlap(room, rooms) {
|
||||
for (const r of rooms) {
|
||||
if (
|
||||
room.x < r.x + r.w &&
|
||||
room.x + room.w > r.x &&
|
||||
room.z < r.z + r.d &&
|
||||
room.z + room.d > r.z
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getCenter(room) {
|
||||
return {
|
||||
x: Math.floor(room.x + room.w / 2),
|
||||
y: room.y,
|
||||
z: Math.floor(room.z + room.d / 2),
|
||||
};
|
||||
}
|
||||
|
||||
buildRoom(r) {
|
||||
for (let x = r.x; x < r.x + r.w; x++) {
|
||||
for (let z = r.z; z < r.z + r.d; z++) {
|
||||
// 1. Build Floor Foundation (y=0)
|
||||
this.grid.setCell(x, r.y - 1, z, 1);
|
||||
|
||||
// 2. Determine if this is a Wall (Perimeter) or Interior
|
||||
const isWall =
|
||||
x === r.x || x === r.x + r.w - 1 || z === r.z || z === r.z + r.d - 1;
|
||||
|
||||
if (isWall) {
|
||||
// Build Wall stack
|
||||
this.grid.setCell(x, r.y, z, 1); // Wall Base
|
||||
this.grid.setCell(x, r.y + 1, z, 1); // Wall Top
|
||||
} else {
|
||||
// Ensure Interior is Air
|
||||
this.grid.setCell(x, r.y, z, 0);
|
||||
this.grid.setCell(x, r.y + 1, z, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildCorridor(start, end) {
|
||||
const stepX = Math.sign(end.x - start.x);
|
||||
let currX = start.x;
|
||||
while (currX !== end.x) {
|
||||
this.buildPathPoint(currX, start.y, start.z);
|
||||
currX += stepX;
|
||||
}
|
||||
|
||||
const stepZ = Math.sign(end.z - start.z);
|
||||
let currZ = start.z;
|
||||
while (currZ !== end.z) {
|
||||
this.buildPathPoint(end.x, start.y, currZ);
|
||||
currZ += stepZ;
|
||||
}
|
||||
}
|
||||
|
||||
buildPathPoint(x, y, z) {
|
||||
// Build Floor
|
||||
this.grid.setCell(x, y - 1, z, 1);
|
||||
// Clear Path (Air)
|
||||
this.grid.setCell(x, y, z, 0);
|
||||
this.grid.setCell(x, y + 1, z, 0);
|
||||
// Optional: Could add walls on sides of corridors here if desired
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +1,180 @@
|
|||
/**
|
||||
* VoxelGrid.js
|
||||
* The pure data representation of the game world.
|
||||
* Uses a flat Uint8Array for high-performance memory access.
|
||||
* The spatial data structure for the game world.
|
||||
* Manages terrain IDs (Uint8Array) and spatial unit lookups (Map).
|
||||
*/
|
||||
export class VoxelGrid {
|
||||
constructor(width, height, depth) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.depth = depth;
|
||||
this.size = { x: width, y: height, z: depth };
|
||||
|
||||
// 0 = Air, 1+ = Solid Blocks
|
||||
// Flat array for terrain IDs (0=Air, 1=Floor, 10=Cover, etc.)
|
||||
this.cells = new Uint8Array(width * height * depth);
|
||||
this.dirty = true; // Flag to tell Renderer to update
|
||||
|
||||
// Spatial Hash for Units: "x,y,z" -> UnitObject
|
||||
this.unitMap = new Map();
|
||||
|
||||
// Hazard Map: "x,y,z" -> { id, duration }
|
||||
this.hazardMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts 3D coordinates to a flat array index.
|
||||
*/
|
||||
getIndex(x, y, z) {
|
||||
return y * this.width * this.depth + z * this.width + x;
|
||||
// --- COORDINATE HELPERS ---
|
||||
_key(x, y, z) {
|
||||
// Handle object input {x,y,z} or raw args
|
||||
if (typeof x === "object") return `${x.x},${x.y},${x.z}`;
|
||||
return `${x},${y},${z}`;
|
||||
}
|
||||
|
||||
_index(x, y, z) {
|
||||
return y * this.size.x * this.size.z + z * this.size.x + x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if coordinates are inside the grid dimensions.
|
||||
*/
|
||||
isValidBounds(x, y, z) {
|
||||
// Handle object input
|
||||
if (typeof x === "object") {
|
||||
z = x.z;
|
||||
y = x.y;
|
||||
x = x.x;
|
||||
}
|
||||
|
||||
return (
|
||||
x >= 0 &&
|
||||
x < this.width &&
|
||||
x < this.size.x &&
|
||||
y >= 0 &&
|
||||
y < this.height &&
|
||||
y < this.size.y &&
|
||||
z >= 0 &&
|
||||
z < this.depth
|
||||
z < this.size.z
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// --- CORE VOXEL MANIPULATION ---
|
||||
|
||||
const index = this.getIndex(x, y, z);
|
||||
getCell(x, y, z) {
|
||||
if (!this.isValidBounds(x, y, z)) return 0; // Out of bounds is Air
|
||||
return this.cells[this._index(x, y, z)];
|
||||
}
|
||||
|
||||
// Only update if changed to save render cycles
|
||||
if (this.cells[index] !== typeId) {
|
||||
this.cells[index] = typeId;
|
||||
this.dirty = true;
|
||||
setCell(x, y, z, id) {
|
||||
if (this.isValidBounds(x, y, z)) {
|
||||
this.cells[this._index(x, y, z)] = id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the voxel ID at the specified position.
|
||||
* Returns 0 (Air) if out of bounds.
|
||||
* Fills the entire grid with a specific ID.
|
||||
* Used by RuinGenerator to create a solid block before carving.
|
||||
*/
|
||||
getVoxel(x, y, z) {
|
||||
if (!this.isValidBounds(x, y, z)) return 0;
|
||||
return this.cells[this.getIndex(x, y, z)];
|
||||
fill(id) {
|
||||
this.cells.fill(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Checks if a voxel is solid (non-air).
|
||||
* Creates a copy of the grid data.
|
||||
* Used by Cellular Automata for smoothing passes.
|
||||
*/
|
||||
isSolid(x, y, z) {
|
||||
return this.getVoxel(x, y, z) !== 0;
|
||||
clone() {
|
||||
const newGrid = new VoxelGrid(this.size.x, this.size.y, this.size.z);
|
||||
newGrid.cells.set(this.cells); // Fast copy
|
||||
return newGrid;
|
||||
}
|
||||
|
||||
// --- QUERY & PHYSICS ---
|
||||
|
||||
isSolid(pos) {
|
||||
const id = this.getCell(pos.x, pos.y, pos.z);
|
||||
return id !== 0; // 0 is Air
|
||||
}
|
||||
|
||||
isOccupied(pos) {
|
||||
return this.unitMap.has(this._key(pos));
|
||||
}
|
||||
|
||||
getUnitAt(pos) {
|
||||
return this.unitMap.get(this._key(pos));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the voxel is destructible cover (IDs 10-20).
|
||||
*/
|
||||
isDestructible(pos) {
|
||||
const id = this.getCell(pos.x, pos.y, pos.z);
|
||||
return id >= 10 && id <= 20;
|
||||
}
|
||||
|
||||
destroyVoxel(pos) {
|
||||
if (this.isDestructible(pos)) {
|
||||
this.setCell(pos.x, pos.y, pos.z, 0); // Turn to Air
|
||||
// TODO: Trigger particle event via EventBus
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for AI to find cover or hazards.
|
||||
* Returns list of {x,y,z,id} objects within radius.
|
||||
*/
|
||||
getVoxelsInRadius(center, radius, filterFn = null) {
|
||||
const results = [];
|
||||
const r = Math.ceil(radius);
|
||||
|
||||
for (let x = center.x - r; x <= center.x + r; x++) {
|
||||
for (let z = center.z - r; z <= center.z + r; z++) {
|
||||
for (let y = center.y - 1; y <= center.y + 2; y++) {
|
||||
// Check varied height
|
||||
if (this.isValidBounds(x, y, z)) {
|
||||
const id = this.getCell(x, y, z);
|
||||
if (!filterFn || filterFn(id, x, y, z)) {
|
||||
results.push({ x, y, z, id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- UNIT MOVEMENT ---
|
||||
|
||||
placeUnit(unit, pos) {
|
||||
// Remove from old location
|
||||
if (unit.position) {
|
||||
const oldKey = this._key(unit.position);
|
||||
if (this.unitMap.get(oldKey) === unit) {
|
||||
this.unitMap.delete(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Update Unit
|
||||
unit.position = { x: pos.x, y: pos.y, z: pos.z };
|
||||
|
||||
// Add to new location
|
||||
this.unitMap.set(this._key(pos), unit);
|
||||
}
|
||||
|
||||
moveUnit(unit, targetPos, options = {}) {
|
||||
if (!this.isValidBounds(targetPos)) return false;
|
||||
|
||||
// Collision Check (can be bypassed by 'force' for Teleport/Swap)
|
||||
if (
|
||||
!options.force &&
|
||||
(this.isSolid(targetPos) || this.isOccupied(targetPos))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.placeUnit(unit, targetPos);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- HAZARDS ---
|
||||
|
||||
addHazard(pos, typeId, duration) {
|
||||
if (this.isValidBounds(pos)) {
|
||||
this.hazardMap.set(this._key(pos), { id: typeId, duration });
|
||||
}
|
||||
}
|
||||
|
||||
getHazardAt(pos) {
|
||||
return this.hazardMap.get(this._key(pos));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,91 +2,121 @@ 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).
|
||||
* Handles the Three.js rendering of the VoxelGrid data.
|
||||
* Uses InstancedMesh for high performance.
|
||||
*/
|
||||
export class VoxelManager {
|
||||
constructor(grid, scene) {
|
||||
constructor(grid, scene, textureAtlas) {
|
||||
this.grid = grid;
|
||||
this.scene = scene;
|
||||
this.textureAtlas = textureAtlas;
|
||||
this.mesh = null;
|
||||
this.needsUpdate = true;
|
||||
|
||||
// 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)
|
||||
];
|
||||
// Define Materials per ID (Simplified for Prototype)
|
||||
// In Phase 3, this will use the Texture Atlas UVs
|
||||
this.material = new THREE.MeshStandardMaterial({ color: 0xffffff });
|
||||
|
||||
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);
|
||||
// Color Map: ID -> Hex
|
||||
this.palette = {
|
||||
1: new THREE.Color(0x555555), // Stone
|
||||
2: new THREE.Color(0x3d2817), // Dirt
|
||||
10: new THREE.Color(0x8b4513), // Wood (Destructible)
|
||||
15: new THREE.Color(0x00ffff), // Crystal
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the visual mesh based on the grid data.
|
||||
* Call this in the game loop if grid.dirty is true.
|
||||
* Initializes the InstancedMesh based on grid size.
|
||||
* Must be called after the grid is populated by WorldGen.
|
||||
*/
|
||||
init() {
|
||||
if (this.mesh) {
|
||||
this.scene.remove(this.mesh);
|
||||
this.mesh.dispose();
|
||||
}
|
||||
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||
const count = this.grid.size.x * this.grid.size.y * this.grid.size.z;
|
||||
|
||||
this.mesh = new THREE.InstancedMesh(geometry, this.material, count);
|
||||
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // Allow updates
|
||||
|
||||
this.scene.add(this.mesh);
|
||||
this.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-calculates positions for all voxels.
|
||||
* Call this when terrain is destroyed or modified.
|
||||
*/
|
||||
update() {
|
||||
if (!this.grid.dirty) return;
|
||||
if (!this.mesh) 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);
|
||||
for (let y = 0; y < this.grid.size.y; y++) {
|
||||
for (let z = 0; z < this.grid.size.z; z++) {
|
||||
for (let x = 0; x < this.grid.size.x; x++) {
|
||||
const cellId = this.grid.getCell(x, y, z);
|
||||
|
||||
if (typeId !== 0) {
|
||||
// Position the dummy object
|
||||
if (cellId !== 0) {
|
||||
// Position the cube
|
||||
dummy.position.set(x, y, z);
|
||||
dummy.updateMatrix();
|
||||
|
||||
// Update the instance matrix
|
||||
// Apply Transform
|
||||
this.mesh.setMatrixAt(instanceId, dummy.matrix);
|
||||
|
||||
// Update the instance color based on ID
|
||||
const color = this.palette[typeId] || new THREE.Color(0xff00ff); // Magenta = Error
|
||||
// Apply Color based on ID
|
||||
const color = this.palette[cellId] || new THREE.Color(0xff00ff); // Magenta = Error
|
||||
this.mesh.setColorAt(instanceId, color);
|
||||
|
||||
instanceId++;
|
||||
} else {
|
||||
// Hide Air voxels by scaling to 0
|
||||
dummy.position.set(0, 0, 0);
|
||||
dummy.scale.set(0, 0, 0);
|
||||
dummy.updateMatrix();
|
||||
this.mesh.setMatrixAt(instanceId, dummy.matrix);
|
||||
// Reset scale for next iteration
|
||||
dummy.scale.set(1, 1, 1);
|
||||
}
|
||||
|
||||
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;
|
||||
if (this.mesh.instanceColor) this.mesh.instanceColor.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;
|
||||
/**
|
||||
* Efficiently updates a single voxel without rebuilding the whole mesh.
|
||||
* Use this for 'destroyVoxel' events.
|
||||
*/
|
||||
updateVoxel(x, y, z) {
|
||||
// Calculate the specific index in the flat array
|
||||
const index =
|
||||
y * this.grid.size.x * this.grid.size.z + z * this.grid.size.x + x;
|
||||
const cellId = this.grid.getCell(x, y, z);
|
||||
const dummy = new THREE.Object3D();
|
||||
|
||||
if (cellId !== 0) {
|
||||
dummy.position.set(x, y, z);
|
||||
dummy.updateMatrix();
|
||||
this.mesh.setMatrixAt(index, dummy.matrix);
|
||||
|
||||
const color = this.palette[cellId] || new THREE.Color(0xff00ff);
|
||||
this.mesh.setColorAt(index, color);
|
||||
} else {
|
||||
// Hide it
|
||||
dummy.scale.set(0, 0, 0);
|
||||
dummy.updateMatrix();
|
||||
this.mesh.setMatrixAt(index, dummy.matrix);
|
||||
}
|
||||
|
||||
this.grid.dirty = false;
|
||||
this.mesh.instanceMatrix.needsUpdate = true;
|
||||
this.mesh.instanceColor.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
src/utils/SeededRandom.js
Normal file
51
src/utils/SeededRandom.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* SeededRandom.js
|
||||
* A deterministic pseudo-random number generator using Mulberry32.
|
||||
* Essential for reproducible procedural generation.
|
||||
*/
|
||||
export class SeededRandom {
|
||||
constructor(seed) {
|
||||
// Hash the string seed to a number if necessary
|
||||
if (typeof seed === "string") {
|
||||
this.state = this.hashString(seed);
|
||||
} else {
|
||||
this.state = seed || Math.floor(Math.random() * 2147483647);
|
||||
}
|
||||
}
|
||||
|
||||
hashString(str) {
|
||||
let hash = 1779033703 ^ str.length;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = Math.imul(hash ^ str.charCodeAt(i), 3432918353);
|
||||
hash = (hash << 13) | (hash >>> 19);
|
||||
}
|
||||
return () => {
|
||||
hash = Math.imul(hash ^ (hash >>> 16), 2246822507);
|
||||
hash = Math.imul(hash ^ (hash >>> 13), 3266489909);
|
||||
return hash >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
// Mulberry32 Algorithm
|
||||
next() {
|
||||
let t = (this.state += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
// Returns float between [min, max)
|
||||
range(min, max) {
|
||||
return min + this.next() * (max - min);
|
||||
}
|
||||
|
||||
// Returns integer between [min, max] (inclusive)
|
||||
rangeInt(min, max) {
|
||||
return Math.floor(this.range(min, max + 1));
|
||||
}
|
||||
|
||||
// Returns true/false based on probability (0.0 - 1.0)
|
||||
chance(probability) {
|
||||
return this.next() < probability;
|
||||
}
|
||||
}
|
||||
79
test/generation/WorldGen.test.js
Normal file
79
test/generation/WorldGen.test.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||
import { CaveGenerator } from "../../src/generation/CaveGenerator.js";
|
||||
import { RuinGenerator } from "../../src/generation/RuinGenerator.js";
|
||||
|
||||
describe("System: Procedural Generation", () => {
|
||||
let grid;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new VoxelGrid(20, 10, 20); // Small test map
|
||||
});
|
||||
|
||||
describe("Cave Generator (Organic)", () => {
|
||||
it("CoA 1: Should modify the grid from empty state", () => {
|
||||
const gen = new CaveGenerator(grid, 12345); // Fixed seed
|
||||
gen.generate(0.5, 1); // 50% fill, 1 pass
|
||||
|
||||
// Check if grid is no longer all 0s
|
||||
let solidCount = 0;
|
||||
for (let i = 0; i < grid.cells.length; i++) {
|
||||
if (grid.cells[i] !== 0) solidCount++;
|
||||
}
|
||||
|
||||
expect(solidCount).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("CoA 2: Should keep borders solid (Containment)", () => {
|
||||
const gen = new CaveGenerator(grid, 12345);
|
||||
gen.generate();
|
||||
|
||||
// Check coordinate 0,0,0 (Corner)
|
||||
expect(grid.isSolid({ x: 0, y: 0, z: 0 })).to.be.true;
|
||||
// Check coordinate 19,0,19 (Opposite Corner)
|
||||
expect(grid.isSolid({ x: 19, y: 0, z: 19 })).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ruin Generator (Structural)", () => {
|
||||
it("CoA 3: Should generate clear rooms", () => {
|
||||
// Fill with solid first to verify carving
|
||||
grid.fill(1);
|
||||
|
||||
const gen = new RuinGenerator(grid, 12345);
|
||||
gen.generate(3, 4, 6); // 3 rooms, size 4-6
|
||||
|
||||
// There should be AIR (0) voxels now
|
||||
let airCount = 0;
|
||||
for (let i = 0; i < grid.cells.length; i++) {
|
||||
if (grid.cells[i] === 0) airCount++;
|
||||
}
|
||||
|
||||
expect(airCount).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("CoA 4: Rooms should have walkable floors", () => {
|
||||
const gen = new RuinGenerator(grid, 12345);
|
||||
gen.generate(1, 5, 5); // 1 room
|
||||
|
||||
// Find an air tile
|
||||
let roomTile = null;
|
||||
for (let x = 0; x < 20; x++) {
|
||||
for (let z = 0; z < 20; z++) {
|
||||
// Check level 1 (where rooms are carved)
|
||||
if (grid.getCell(x, 1, z) === 0) {
|
||||
roomTile = { x, y: 1, z };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (roomTile) break;
|
||||
}
|
||||
|
||||
expect(roomTile).to.not.be.null;
|
||||
|
||||
// The tile BELOW the air should be solid
|
||||
const floor = grid.getCell(roomTile.x, roomTile.y - 1, roomTile.z);
|
||||
expect(floor).to.not.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,96 +1,72 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||
|
||||
describe("Phase 1: VoxelGrid Data Structure", () => {
|
||||
describe("Phase 1: VoxelGrid Implementation", () => {
|
||||
let grid;
|
||||
const width = 10;
|
||||
const height = 10;
|
||||
const depth = 10;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new VoxelGrid(width, height, depth);
|
||||
grid = new VoxelGrid(10, 5, 10);
|
||||
});
|
||||
|
||||
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 1: Should store and retrieve IDs", () => {
|
||||
grid.setCell(1, 1, 1, 5);
|
||||
expect(grid.getCell(1, 1, 1)).to.equal(5);
|
||||
});
|
||||
|
||||
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 2: Should handle out of bounds gracefully", () => {
|
||||
expect(grid.getCell(-1, 0, 0)).to.equal(0);
|
||||
expect(grid.getCell(20, 20, 20)).to.equal(0);
|
||||
});
|
||||
|
||||
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("Should fill grid with value", () => {
|
||||
grid.fill(1); // Set all to Stone
|
||||
expect(grid.getCell(0, 0, 0)).to.equal(1);
|
||||
expect(grid.getCell(9, 4, 9)).to.equal(1);
|
||||
});
|
||||
|
||||
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("Should clone grid data correctly", () => {
|
||||
grid.setCell(5, 0, 5, 99);
|
||||
const copy = grid.clone();
|
||||
|
||||
expect(copy.getCell(5, 0, 5)).to.equal(99);
|
||||
|
||||
// Modify original, check copy is independent
|
||||
grid.setCell(5, 0, 5, 0);
|
||||
expect(copy.getCell(5, 0, 5)).to.equal(99);
|
||||
});
|
||||
|
||||
it("CoA 5: Should handle isSolid checks", () => {
|
||||
grid.setVoxel(1, 1, 1, 1); // Solid
|
||||
grid.setVoxel(2, 2, 2, 0); // Air
|
||||
it("Should find voxels in radius", () => {
|
||||
// Setup a wall at 2,0,2
|
||||
grid.setCell(2, 0, 2, 1);
|
||||
const center = { x: 0, y: 0, z: 0 };
|
||||
|
||||
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
|
||||
// Search radius 3 for ID 1
|
||||
const results = grid.getVoxelsInRadius(center, 3, (id) => id === 1);
|
||||
|
||||
expect(results).to.have.lengthOf(1);
|
||||
expect(results[0].x).to.equal(2);
|
||||
});
|
||||
|
||||
it("CoA 6: Should manage the dirty flag correctly", () => {
|
||||
// Initial state
|
||||
expect(grid.dirty).to.be.true;
|
||||
it("Should track unit placement", () => {
|
||||
const unit = { id: "u1" };
|
||||
const pos = { x: 5, y: 0, z: 5 };
|
||||
|
||||
// Reset flag manually (simulating a render cycle completion)
|
||||
grid.dirty = false;
|
||||
grid.placeUnit(unit, pos);
|
||||
|
||||
// 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
|
||||
expect(grid.isOccupied(pos)).to.be.true;
|
||||
expect(grid.getUnitAt(pos)).to.equal(unit);
|
||||
expect(unit.position).to.deep.equal(pos);
|
||||
});
|
||||
|
||||
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);
|
||||
it("Should handle destructible terrain", () => {
|
||||
const pos = { x: 3, y: 0, z: 3 };
|
||||
grid.setCell(pos.x, pos.y, pos.z, 15); // ID 15 = Destructible
|
||||
|
||||
// x=0, y=0, z=1 -> 1 * 10 = 10
|
||||
expect(grid.getIndex(0, 0, 1)).to.equal(10);
|
||||
expect(grid.isDestructible(pos)).to.be.true;
|
||||
|
||||
// x=0, y=1, z=0 -> 1 * 10 * 10 = 100
|
||||
expect(grid.getIndex(0, 1, 0)).to.equal(100);
|
||||
const success = grid.destroyVoxel(pos);
|
||||
|
||||
expect(success).to.be.true;
|
||||
expect(grid.getCell(pos.x, pos.y, pos.z)).to.equal(0); // Now Air
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,124 +1,64 @@
|
|||
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";
|
||||
import * as THREE from "three";
|
||||
|
||||
describe("Phase 1: VoxelManager (Renderer)", () => {
|
||||
describe.skip("Phase 1: VoxelManager Rendering (WebGL)", () => {
|
||||
let grid;
|
||||
let scene;
|
||||
let manager;
|
||||
let renderer;
|
||||
|
||||
before(() => {
|
||||
// 1. Setup a real WebGL Renderer (Headless)
|
||||
const canvas = document.createElement("canvas");
|
||||
// Force context creation to check support
|
||||
const context = canvas.getContext("webgl");
|
||||
if (!context) {
|
||||
console.warn(
|
||||
"WebGL not supported in this test environment. Skipping render checks."
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ canvas, context });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup a basic scene and grid
|
||||
grid = new VoxelGrid(4, 4, 4);
|
||||
scene = new THREE.Scene();
|
||||
grid = new VoxelGrid(4, 4, 4); // Small 4x4x4 grid
|
||||
manager = new VoxelManager(grid, scene);
|
||||
manager = new VoxelManager(grid, scene, null);
|
||||
});
|
||||
|
||||
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);
|
||||
it("CoA 1: init() should create a real InstancedMesh in the scene", () => {
|
||||
grid.fill(1); // Fill with stone
|
||||
manager.init();
|
||||
|
||||
const mesh = scene.children.find((c) => c.isInstancedMesh);
|
||||
expect(mesh).to.exist;
|
||||
expect(mesh.count).to.equal(64); // 4*4*4
|
||||
});
|
||||
|
||||
it("CoA 2: update() should correctly position instances", () => {
|
||||
grid.setCell(0, 0, 0, 1); // Only one block
|
||||
manager.init();
|
||||
|
||||
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();
|
||||
mesh.getMatrixAt(0, matrix); // Get transform of first block
|
||||
|
||||
// Get matrix of first instance
|
||||
mesh.getMatrixAt(0, matrix);
|
||||
const position = new THREE.Vector3().setFromMatrixPosition(matrix);
|
||||
|
||||
const position = new THREE.Vector3();
|
||||
position.setFromMatrixPosition(matrix);
|
||||
expect(position.x).to.equal(0);
|
||||
expect(position.y).to.equal(0);
|
||||
expect(position.z).to.equal(0);
|
||||
});
|
||||
|
||||
expect(position.x).to.equal(targetX);
|
||||
expect(position.y).to.equal(targetY);
|
||||
expect(position.z).to.equal(targetZ);
|
||||
it("CoA 3: render loop should not crash", () => {
|
||||
// Verify we can actually call render() without WebGL errors
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
manager.init();
|
||||
|
||||
expect(() => renderer.render(scene, camera)).to.not.throw();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
67
test/utils/SeededRandom.test.js
Normal file
67
test/utils/SeededRandom.test.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||
import { RuinGenerator } from "../../src/generation/RuinGenerator.js";
|
||||
|
||||
describe("System: Procedural Generation (Scatter)", () => {
|
||||
let grid;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new VoxelGrid(20, 5, 20);
|
||||
});
|
||||
|
||||
it("CoA 1: scatterCover should place objects on valid floors", () => {
|
||||
const gen = new RuinGenerator(grid, 12345);
|
||||
|
||||
// 1. Generate empty rooms first
|
||||
gen.generate(1, 10, 10);
|
||||
|
||||
// 2. Count empty floor tiles (Air above Stone)
|
||||
let floorCount = 0;
|
||||
for (let x = 0; x < 20; x++) {
|
||||
for (let z = 0; z < 20; z++) {
|
||||
if (grid.getCell(x, 1, z) === 0 && grid.getCell(x, 0, z) === 1) {
|
||||
floorCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scatter Cover (ID 10) at 50% density
|
||||
gen.scatterCover(10, 0.5);
|
||||
|
||||
// 4. Count Cover
|
||||
let coverCount = 0;
|
||||
for (let x = 0; x < 20; x++) {
|
||||
for (let z = 0; z < 20; z++) {
|
||||
if (grid.getCell(x, 1, z) === 10) {
|
||||
coverCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expect roughly 50% of the floor to be covered
|
||||
// We use a range because RNG varies slightly
|
||||
const expectedMin = floorCount * 0.4;
|
||||
const expectedMax = floorCount * 0.6;
|
||||
|
||||
expect(coverCount).to.be.within(expectedMin, expectedMax);
|
||||
});
|
||||
|
||||
it("CoA 2: scatterCover should NOT place objects in mid-air", () => {
|
||||
const gen = new RuinGenerator(grid, 12345);
|
||||
gen.generate();
|
||||
gen.scatterCover(10, 1.0); // 100% density to force errors if logic is wrong
|
||||
|
||||
// Scan for floating cover
|
||||
for (let x = 0; x < 20; x++) {
|
||||
for (let z = 0; z < 20; z++) {
|
||||
for (let y = 1; y < 4; y++) {
|
||||
if (grid.getCell(x, y, z) === 10) {
|
||||
const below = grid.getCell(x, y - 1, z);
|
||||
// Cover must have something solid below it
|
||||
expect(below).to.not.equal(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
32
web-test-runner.config.js
Normal file
32
web-test-runner.config.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { puppeteerLauncher } from "@web/test-runner-puppeteer";
|
||||
|
||||
export default {
|
||||
nodeResolve: true,
|
||||
files: ["test/**/*.test.js"],
|
||||
coverage: true,
|
||||
browsers: [
|
||||
puppeteerLauncher({
|
||||
launchOptions: {
|
||||
// Use the new headless mode explicitly
|
||||
headless: "new",
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
// Critical flags for WebGL in headless:
|
||||
"--enable-gpu", // Required to trigger the graphics stack
|
||||
"--ignore-gpu-blocklist", // Force access to the "GPU" (SwiftShader)
|
||||
"--use-gl=swiftshader", // The software renderer
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
testFramework: {
|
||||
config: {
|
||||
ui: "bdd",
|
||||
// WebGL initialization in software mode can be slow, so we bump the timeout
|
||||
timeout: "10000",
|
||||
},
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue