diff --git a/package.json b/package.json index 7c26322..b41d508 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "main": "index.js", "scripts": { "build": "node build.js", - "start": "web-dev-server --node-resolve --watch --root-dir dist", + "start": "web-dev-server --node-resolve --watch --root-dir --port 8000 dist", "test:all": "web-test-runner \"test/**/*.test.js\" --node-resolve", "test": "web-test-runner --node-resolve", - "test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch --config web-test-runner.config.js" + "test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch --config web-test-runner.config.js", + "visualizer": "web-dev-server --node-resolve --watch --port 8001 src/tools/map-visualizer.html" }, "repository": { "type": "git", diff --git a/specs/Biomes.spec.md b/specs/Biomes.spec.md new file mode 100644 index 0000000..6937a36 --- /dev/null +++ b/specs/Biomes.spec.md @@ -0,0 +1,43 @@ +# **1. Biome Visual & Tactical Reference** + +This section details the atmospheric and gameplay characteristics for each biome to guide asset generation (textures, scatter cover) and level design. + +## **A. Fungal Caves (Magic / Organic)** + +- **Look & Feel:** Claustrophobic, damp, and bioluminescent. Walls are dark grey stone slick with moisture, cracked by pulsing purple and cyan fungal veins (Aether). The air is hazy with drifting green spores. +- **Scattered Cover:** + - _Soft Cover:_ Large, rubbery blue/purple mushroom caps. + - _Hard Cover:_ Petrified roots and jagged stalagmites. +- **Tactical Geometry:** Tight, twisting corridors with frequent chokepoints. Low ceiling clearance. Uneven floors. + +## **B. Rusting Wastes (Tech / Industrial)** + +- **Look & Feel:** Vast, abandoned factory floors and scrapyards. The primary palette is oxidized orange rust, oily black puddles, and dull brass machinery. Smog hangs low in the air. +- **Scattered Cover:** + - _Destructible Cover:_ Stacks of rusty crates, barrels of sludge, old server racks. + - _Hard Cover:_ Heavy machinery blocks, dormant conveyor belts. +- **Tactical Geometry:** Wide open arenas with long sightlines favored by snipers. Grid-based layouts with artificial cover placed in rows. + +## **C. Crystal Spires (Mixed / Vertical)** + +- **Look & Feel:** Floating islands of white marble and raw blue Aether crystal suspended in a bright, infinite sky. Clean, sharp lines and magical aesthetics. Light bridges connect disconnected platforms. +- **Scattered Cover:** + - _Cover:_ Clusters of jagged Aether crystals (translucent blue/white). + - _Hazards:_ The edges of the map drop into the infinite void (Instant Death). +- **Tactical Geometry:** Extreme verticality. Multiple layers of elevation requiring climbing or teleportation. + +## **D. Void-Seep Depths (Horror / Corruption)** + +- **Look & Feel:** Reality breaking down. Obsidian black stone dripping with neon violet corruption. Glitchy visual effects, floating debris, and an oppressive, dark alien atmosphere. +- **Scattered Cover:** + - _Cover:_ Pulsing organic tumors, crystallized void matter. + - _Unstable:_ Some cover objects may explode or vanish when damaged. +- **Tactical Geometry:** Arena-style circular maps with few obstacles but dangerous hazard zones (void rifts). + +### **E. Contested Frontier (Surface / War)** + +- **Look & Feel:** A scarred battlefield under a twilight sky. Muddy trenches, green grass torn up by troop movements, and hastily erected fortifications. This is the only biome with "natural" outdoor lighting. +- **Scattered Cover:** + - _Fortifications:_ Sandbag walls, wooden cheval de frise, abandoned supply tents. + - _Nature:_ Tree stumps and large rocks. +- **Tactical Geometry:** Linear "Lane" based maps mimicking trench warfare. High cover density. diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 4dceb2f..7c61bf5 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -13,7 +13,10 @@ import { VoxelGrid } from "../grid/VoxelGrid.js"; import { VoxelManager } from "../grid/VoxelManager.js"; import { UnitManager } from "../managers/UnitManager.js"; import { CaveGenerator } from "../generation/CaveGenerator.js"; -import { RuinGenerator } from "../generation/RuinGenerator.js"; +import { RustingWastesGenerator } from "../generation/RustingWastesGenerator.js"; +import { ContestedFrontierGenerator } from "../generation/ContestedFrontierGenerator.js"; +import { CrystalSpiresGenerator } from "../generation/CrystalSpiresGenerator.js"; +import { VoidSeepDepthsGenerator } from "../generation/VoidSeepDepthsGenerator.js"; import { InputManager } from "./InputManager.js"; import { MissionManager } from "../managers/MissionManager.js"; import { TurnSystem } from "../systems/TurnSystem.js"; @@ -1169,7 +1172,29 @@ export class GameLoop { this.animate(); this.grid = new VoxelGrid(20, 10, 20); - const generator = new RuinGenerator(this.grid, runData.seed); + + let generator; + const biomeType = runData.biome?.type || "BIOME_RUSTING_WASTES"; + + switch (biomeType) { + case "BIOME_CONTESTED_FRONTIER": + generator = new ContestedFrontierGenerator(this.grid, runData.seed); + break; + case "BIOME_CRYSTAL_SPIRES": + generator = new CrystalSpiresGenerator(this.grid, runData.seed); + break; + case "BIOME_VOID_SEEP": + generator = new VoidSeepDepthsGenerator(this.grid, runData.seed); + break; + case "BIOME_FUNGAL_CAVES": + generator = new CaveGenerator(this.grid, runData.seed); + break; + case "BIOME_RUSTING_WASTES": + default: + generator = new RustingWastesGenerator(this.grid, runData.seed); + break; + } + generator.generate(); if (generator.generatedAssets.spawnZones) { diff --git a/src/generation/CaveGenerator.js b/src/generation/CaveGenerator.js index bf67bee..c752aa2 100644 --- a/src/generation/CaveGenerator.js +++ b/src/generation/CaveGenerator.js @@ -1,6 +1,7 @@ import { BaseGenerator } from "./BaseGenerator.js"; import { DampCaveTextureGenerator } from "./textures/DampCaveTextureGenerator.js"; import { BioluminescentCaveWallTextureGenerator } from "./textures/BioluminescentCaveWallTextureGenerator.js"; +import { SimplexNoise } from "../utils/SimplexNoise.js"; /** * Generates organic, open-topped caves using Cellular Automata. @@ -19,6 +20,7 @@ export class CaveGenerator extends BaseGenerator { // The VoxelManager will need to read this 'palette' to create materials. this.generatedAssets = { palette: {}, // Maps Voxel ID -> Texture Asset Config + spawnZones: { player: [], enemy: [] }, }; this.preloadTextures(); @@ -32,23 +34,27 @@ export class CaveGenerator extends BaseGenerator { const VARIATIONS = 10; const TEXTURE_SIZE = 128; - // 1. Preload Wall Variations (IDs 100 - 109) - for (let i = 0; i < VARIATIONS; i++) { - // Vary the seed slightly for each texture to get unique noise patterns - // but keep them consistent across re-runs of the same level seed - const wallSeed = this.rng.next() * 5000 + i; + // 4. Generate Wall Textures + // IDs: 100-107 (Plain), 108-109 (Veined) + const wallGen = new BioluminescentCaveWallTextureGenerator(this.seed); - // Generate the complex map (Diffuse + Emissive + Normal etc.) - // We use a new generator instance or just rely on the internal noise - // if the generator supports offset/variant seeding (assuming basic usage here) - // Ideally generators support a 'variantSeed' param in generate calls. - // Since BioluminescentCaveWallTextureGenerator creates a new Simplex(seed) in constructor, - // we instantiate a new one for variation or update the generator to support methods. - // For efficiency here, we assume the generators handle unique output if re-instantiated or parametrized. - - const tempWallGen = new BioluminescentCaveWallTextureGenerator(wallSeed); - this.generatedAssets.palette[100 + i] = - tempWallGen.generateWall(TEXTURE_SIZE); + // Plain Walls (Majority) + for (let i = 0; i < 8; i++) { + // disable veins for plain walls + this.generatedAssets.palette[100 + i] = wallGen.generateWall( + 64, + i, + false + ); + } + // Veined Walls (Sparse) + for (let i = 0; i < 2; i++) { + // enable veins + this.generatedAssets.palette[108 + i] = wallGen.generateWall( + 64, + i + 10, + true + ); } // 2. Preload Floor Variations (IDs 200 - 209) @@ -63,59 +69,263 @@ export class CaveGenerator extends BaseGenerator { } generate(fillPercent = 0.45, iterations = 4) { - // 1. Initial Noise + // 1. Initialize 2D Map + // 1 = Wall, 0 = Floor + let map = []; for (let x = 0; x < this.width; x++) { + map[x] = []; for (let z = 0; z < this.depth; z++) { - for (let y = 0; y < this.height; y++) { - // RULE 1: Foundation is always solid. - if (y === 0) { - this.grid.setCell(x, y, z, 100); // Default to Wall 0 - continue; - } - - // RULE 2: Sky is always clear. - if (y >= this.height - 1) { - this.grid.setCell(x, y, z, 0); - continue; - } - - // RULE 3: Noise for the middle layers. - const isSolid = this.rng.chance(fillPercent); - // We use a placeholder ID (1) for calculation, applied later - this.grid.setCell(x, y, z, isSolid ? 1 : 0); + // Border is always wall + if ( + x === 0 || + x === this.width - 1 || + z === 0 || + z === this.depth - 1 + ) { + map[x][z] = 1; + } else { + map[x][z] = this.rng.chance(fillPercent) ? 1 : 0; } } } - // 2. Smoothing Iterations (Calculates based on ID != 0) + // 2. Smooth 2D Map for (let i = 0; i < iterations; i++) { - this.smooth(); + map = this.smoothMap(map); } - // 3. Apply Texture/Material Logic - // This replaces the placeholder IDs with our specific texture IDs (100-109, 200-209) + // 3. Post-Process: Ensure Connectivity + map = this.ensureConnectivity(map); + + // 4. Extrude to 3D Voxel Grid + for (let x = 0; x < this.width; x++) { + for (let z = 0; z < this.depth; z++) { + const isWall = map[x][z] === 1; + + if (isWall) { + // Wall Logic + // Vary height slightly for "voxel aesthetic" + // height-1 or height + const wallHeight = this.height - (this.rng.next() > 0.5 ? 1 : 0); + + for (let y = 0; y < wallHeight; y++) { + // Use provisional ID 100 for walls, we will texture later or set directly here + // Just for consistency with previous flow, we'll use placeholder 100 + this.grid.setCell(x, y, z, 100); + } + } else { + // Floor Logic + // y=0 is foundation (always solid) + this.grid.setCell(x, 0, z, 100); + } + } + } + + // 5. Apply Texture/Material Logic this.applyTextures(); - // 4. Scatter Cover (Post-Texturing) + // 6. Scatter Cover (Post-Texturing) // ID 10 = Destructible Cover (Mushrooms/Rocks) - // 5% Density for open movement + // 5% Density on floor tiles this.scatterCover(10, 0.05); + + // 7. Scatter Wall Details (Spores/Moss) + // ID 11 = Wall Detail + // 5% Density on wall surfaces above y=2 + this.scatterWallDetails(11, 0.05, 2); + + // 8. Define Deployment Zones + // Player Z-min, Enemy Z-max + this.defineSpawnZones(); } - smooth() { - const nextGrid = this.grid.clone(); - - for (let x = 0; x < this.width; x++) { - for (let z = 0; z < this.depth; z++) { - for (let y = 1; y < this.height - 1; y++) { - const neighbors = this.getSolidNeighbors(x, y, z); - - if (neighbors > 13) nextGrid.setCell(x, y, z, 1); - else if (neighbors < 13) nextGrid.setCell(x, y, z, 0); + defineSpawnZones() { + // Scan Z < 5 for Player + for (let x = 1; x < this.width - 1; x++) { + for (let z = 1; z < 5; z++) { + if ( + this.grid.getCell(x, 0, z) !== 0 && + this.grid.getCell(x, 1, z) === 0 + ) { + this.generatedAssets.spawnZones.player.push({ x, y: 1, z }); } } } - this.grid.cells = nextGrid.cells; + + // Scan Z > Depth - 5 for Enemy + for (let x = 1; x < this.width - 1; x++) { + for (let z = this.depth - 6; z < this.depth - 1; z++) { + if ( + this.grid.getCell(x, 0, z) !== 0 && + this.grid.getCell(x, 1, z) === 0 + ) { + this.generatedAssets.spawnZones.enemy.push({ x, y: 1, z }); + } + } + } + } + + ensureConnectivity(map) { + const regions = []; + const visited = new Set(); + const floorCells = []; + + // 1. Identify all regions + for (let x = 0; x < this.width; x++) { + for (let z = 0; z < this.depth; z++) { + if (map[x][z] === 0 && !visited.has(`${x},${z}`)) { + const region = []; + this.floodFill(map, x, z, visited, region); + regions.push(region); + } + } + } + + // If no regions (all walls?), return map as is or clear a center room. + if (regions.length === 0) return map; + + // 2. Find Largest Region + regions.sort((a, b) => { + if (b.length !== a.length) { + return b.length - a.length; + } + // Deterministic tie-breaker using first cell coordinates + if (a.length > 0 && b.length > 0) { + const keyA = a[0].x * 1000 + a[0].z; + const keyB = b[0].x * 1000 + b[0].z; + return keyB - keyA; + } + return 0; + }); + const mainRegion = regions[0]; + + // 3. Fill all others with walls + // (We construct a new map where only mainRegion is 0, rest is 1) + const newMap = []; + for (let x = 0; x < this.width; x++) { + newMap[x] = []; + for (let z = 0; z < this.depth; z++) { + newMap[x][z] = 1; // Default to wall + } + } + + for (const pos of mainRegion) { + newMap[pos.x][pos.z] = 0; + } + + return newMap; + } + + floodFill(map, startX, startZ, visited, region) { + const stack = [{ x: startX, z: startZ }]; + visited.add(`${startX},${startZ}`); + + while (stack.length > 0) { + const { x, z } = stack.pop(); + region.push({ x, z }); + + const neighbors = [ + { x: x + 1, z: z }, + { x: x - 1, z: z }, + { x: x, z: z + 1 }, + { x: x, z: z - 1 }, + ]; + + for (const n of neighbors) { + if (n.x >= 0 && n.x < this.width && n.z >= 0 && n.z < this.depth) { + const key = `${n.x},${n.z}`; + if (map[n.x][n.z] === 0 && !visited.has(key)) { + visited.add(key); + stack.push(n); + } + } + } + } + } + + /** + * Spreads details on wall surfaces. + * Target: Air block that has at least one solid neighbor at the same Y level. + * @param {number} objectId + * @param {number} density + * @param {number} minHeight + */ + scatterWallDetails(objectId, density, minHeight = 2) { + const validSpots = []; + + for (let x = 1; x < this.width - 1; x++) { + for (let z = 1; z < this.depth - 1; z++) { + for (let y = minHeight; y < this.height - 1; y++) { + // Must be empty to place an object + if (this.grid.getCell(x, y, z) !== 0) continue; + + // Check 4 horizontal neighbors for a wall + // We only care if IT IS touching a wall + if ( + this.grid.isSolid({ x: x + 1, y, z }) || + this.grid.isSolid({ x: x - 1, y, z }) || + this.grid.isSolid({ x, y, z: z + 1 }) || + this.grid.isSolid({ x, y, z: z - 1 }) + ) { + validSpots.push({ x, y, z }); + } + } + } + } + + for (const pos of validSpots) { + if (this.rng.chance(density)) { + this.grid.setCell(pos.x, pos.y, pos.z, objectId); + } + } + } + + smoothMap(map) { + const newMap = []; + for (let x = 0; x < this.width; x++) { + newMap[x] = []; + for (let z = 0; z < this.depth; z++) { + // Borders stay walls + if ( + x === 0 || + x === this.width - 1 || + z === 0 || + z === this.depth - 1 + ) { + newMap[x][z] = 1; + continue; + } + + const neighbors = this.get2DNeighbors(map, x, z); + if (neighbors > 4) newMap[x][z] = 1; + else if (neighbors < 4) newMap[x][z] = 0; + else newMap[x][z] = map[x][z]; + } + } + return newMap; + } + + get2DNeighbors(map, gridX, gridZ) { + let count = 0; + for (let i = -1; i <= 1; i++) { + for (let j = -1; j <= 1; j++) { + if (i === 0 && j === 0) continue; + const checkX = gridX + i; + const checkZ = gridZ + j; + // Check bounds, out of bounds counts as wall + if ( + checkX < 0 || + checkX >= this.width || + checkZ < 0 || + checkZ >= this.depth + ) { + count++; + } else if (map[checkX][checkZ] === 1) { + count++; + } + } + } + return count; } /** @@ -123,25 +333,40 @@ export class CaveGenerator extends BaseGenerator { * assigning randomized IDs from our preloaded palette. */ applyTextures() { + // Noise for vein distribution + // Low frequency for large coherent patches + const veinNoise = new SimplexNoise(this.seed + 999); + for (let x = 0; x < this.width; x++) { for (let z = 0; z < this.depth; z++) { - for (let y = 0; y < this.height; y++) { - const current = this.grid.getCell(x, y, z); - const above = this.grid.getCell(x, y + 1, z); + // Determine if this column is a "Vein Area" + // Scale 0.1 gives features around 10 blocks wide, typical for "sections" + const veinVal = veinNoise.noise2D(x * 0.1, z * 0.1); - if (current !== 0) { - if (above === 0) { - // This is a Floor Surface - // Pick random ID from 200 to 209 - const variant = this.rng.rangeInt(0, 9); - this.grid.setCell(x, y, z, 200 + variant); - } else { - // This is a Wall - // Pick random ID from 100 to 109 - const variant = this.rng.rangeInt(0, 9); + // Threshold: Only top 30% are veins (sparse) + const isVeinArea = veinVal > 0.4; + + let variant; + if (isVeinArea) { + // Veined IDs: 108-109 + variant = 8 + this.rng.rangeInt(0, 1); + } else { + // Plain IDs: 100-107 + variant = this.rng.rangeInt(0, 7); + } + + const isWallColumn = this.grid.getCell(x, 1, z) !== 0; + if (isWallColumn) { + for (let y = 0; y < this.height; y++) { + if (this.grid.getCell(x, y, z) !== 0) { + // Use the selected variant for the whole column this.grid.setCell(x, y, z, 100 + variant); } } + } else { + // Floors + const floorVariant = this.rng.rangeInt(0, 9); + this.grid.setCell(x, 0, z, 200 + floorVariant); } } } diff --git a/src/generation/ContestedFrontierGenerator.js b/src/generation/ContestedFrontierGenerator.js new file mode 100644 index 0000000..033d4b5 --- /dev/null +++ b/src/generation/ContestedFrontierGenerator.js @@ -0,0 +1,257 @@ +import { BaseGenerator } from "./BaseGenerator.js"; +import { TrenchTextureGenerator } from "./textures/TrenchTextureGenerator.js"; +import { SimplexNoise } from "../utils/SimplexNoise.js"; + +/** + * Generates "Contested Frontier" biome: + * - Linear trench lanes. + * - Shell craters. + * - Fortifications. + */ +export class ContestedFrontierGenerator extends BaseGenerator { + constructor(grid, seed) { + super(grid, seed); + + this.textureGen = new TrenchTextureGenerator(seed); + this.generatedAssets = { + palette: {}, + spawnZones: { player: [], enemy: [] }, + }; + + this.preloadTextures(); + } + + preloadTextures() { + const VARIATIONS = 10; + const TEXTURE_SIZE = 128; + + // Walls (120-129) + for (let i = 0; i < VARIATIONS; i++) { + const seed = this.seed + i * 100; + this.generatedAssets.palette[120 + i] = this.textureGen.generateWall( + TEXTURE_SIZE, + seed + ); + } + + // Floors (220-229) + // We split into 3 tiers of 3-4 variants each + // 220-222: Muddy (Bias -0.6) + // 223-226: Mixed (Bias 0.0) + // 227-229: Grassy (Bias 0.6) + + // Muddy + for (let i = 0; i < 3; i++) { + const seed = this.seed + i * 200; + this.generatedAssets.palette[220 + i] = this.textureGen.generateFloor( + TEXTURE_SIZE, + seed, + -0.6 + ); + } + // Mixed + for (let i = 0; i < 4; i++) { + const seed = this.seed + (i + 3) * 200; + this.generatedAssets.palette[223 + i] = this.textureGen.generateFloor( + TEXTURE_SIZE, + seed, + 0.0 + ); + } + // Grassy + for (let i = 0; i < 3; i++) { + const seed = this.seed + (i + 7) * 200; + this.generatedAssets.palette[227 + i] = this.textureGen.generateFloor( + TEXTURE_SIZE, + seed, + 0.6 + ); + } + } + + generate() { + // 1. Terrain Shape + // We want a central trench (lower Y) surrounded by higher ground (banks). + // The trench should meander slightly along the Z-axis (if lane is X-axis) or X-axis (if lane is Z-axis). + // Let's assume the "Lane" is along the Z-axis (player starts at Z=0, enemy at Z=end). + + const trenchNoise = new SimplexNoise(this.seed); + + // Parameters + const bankHeight = 5; + const trenchFloorY = 2; // Depth of trench + const trenchWidth = 8; // Width in blocks + + for (let x = 0; x < this.width; x++) { + for (let z = 0; z < this.depth; z++) { + // Calculate Trench Center per Z slice + // Meander the center X coordinate + const meander = trenchNoise.noise2D(z * 0.1, 0) * (this.width * 0.2); + const centerX = this.width / 2 + meander; + + // Distance from current X to center X + const dist = Math.abs(x - centerX); + + // Determine Ground Height + // If within trench width, low height. If outside, bank height. + // We use smooth interpolation for a slope + let targetHeight = bankHeight; + + const halfWidth = trenchWidth / 2; + if (dist < halfWidth) { + targetHeight = trenchFloorY; + } else if (dist < halfWidth + 3) { + // Slope area (3 blocks wide) + const t = (dist - halfWidth) / 3; // 0 to 1 + targetHeight = trenchFloorY + t * (bankHeight - trenchFloorY); + } + + // Add some noise to the ground height + const groundNoise = trenchNoise.noise2D(x * 0.2, z * 0.2) * 1.5; + let yHeight = Math.floor(targetHeight + groundNoise); + + // Clamp height + if (yHeight < 1) yHeight = 1; + if (yHeight > this.height - 2) yHeight = this.height - 2; + + // Fill Voxels + for (let y = 0; y <= yHeight; y++) { + // Pick Texture ID + // If it's a "Wall" (vertical step) use 120+, else Floor 220+ + // Simple heuristic: fill solid. We will texture pass later. + this.grid.setCell(x, y, z, 1); // Temp solid + } + } + } + + // 2. Texture Pass + this.applyTextures(); + + // 3. Scatter Fortifications (Sandbags, etc) + this.scatterFortifications(); + + // 4. Scatter Nature (Stumps, Rocks) + this.scatterCover(10, 0.03); // Use standard cover ID 10 for nature + + // 5. Define Spawn Zones + // Player at Z-Start, Enemy at Z-End + for (let x = 1; x < this.width - 1; x++) { + for (let z = 1; z < 5; z++) { + // Player Zone (Z < 5) + this.checkSpawnSpot(x, z, "player"); + } + for (let z = this.depth - 6; z < this.depth - 1; z++) { + // Enemy Zone (Z > Depth-6) + this.checkSpawnSpot(x, z, "enemy"); + } + } + } + + checkSpawnSpot(x, z, type) { + // Find surface Y + let y = this.height - 1; + while (y > 0 && this.grid.getCell(x, y, z) === 0) { + y--; + } + + // Must be solid floor + if (this.grid.getCell(x, y, z) !== 0) { + // Add spawn (standing one block above floor) + this.generatedAssets.spawnZones[type].push({ x, y: y + 1, z }); + } + } + + applyTextures() { + // Noise for biome distribution (Mud vs Grass patches) + // Frequency 0.1 gives similar scale to existing generators + const biomeNoise = new SimplexNoise(this.seed + 999); + + for (let x = 0; x < this.width; x++) { + for (let z = 0; z < this.depth; z++) { + // Find surface Y + let maxY = -1; + for (let y = this.height - 1; y >= 0; y--) { + if (this.grid.getCell(x, y, z) !== 0) { + maxY = y; + break; + } + } + + if (maxY !== -1) { + // Determine Biome Patch Type + const noiseVal = biomeNoise.noise2D(x * 0.15, z * 0.15); // -1 to 1 + + let textureId; + + if (noiseVal < -0.3) { + // Muddy Patch (220-222) + textureId = 220 + this.rng.rangeInt(0, 2); + } else if (noiseVal > 0.3) { + // Grassy Patch (227-229) + textureId = 227 + this.rng.rangeInt(0, 2); + } else { + // Mixed Patch (223-226) + textureId = 223 + this.rng.rangeInt(0, 3); + } + + // Surface Block: Floor Texture + this.grid.setCell(x, maxY, z, textureId); + + // Blocks below: Wall/Dirt Texture + for (let y = 0; y < maxY; y++) { + this.grid.setCell(x, y, z, 120 + (y % 5)); // varied dirt + } + } + } + } + } + + scatterFortifications() { + // Place Sandbags (ID 12) along the edges of the trench (Top of the slope) + // Logic: If I am on a high block and neighbor is significantly lower, place sandbag + + for (let x = 1; x < this.width - 1; x++) { + for (let z = 1; z < this.depth - 1; z++) { + // Find surface y + let y = 0; + while (y < this.height && this.grid.getCell(x, y + 1, z) !== 0) { + y++; + } + // y is now the top solid block level (assuming we found one) + if (this.grid.getCell(x, y, z) === 0) continue; // Should be solid + + // Check neighbors for dropoff + const neighbors = [ + { x: x + 1, z }, + { x: x - 1, z }, + { x, z: z + 1 }, + { x, z: z - 1 }, + ]; + + let isRidge = false; + for (const n of neighbors) { + // Get neighbor height + let ny = 0; + // scan up to reasonable height + while ( + ny < this.height && + this.grid.getCell(n.x, ny + 1, n.z) !== 0 + ) { + ny++; + } + + // If neighbor is 2+ blocks lower, we are on a ridge/trench lip + if (y > ny + 1) { + isRidge = true; + break; + } + } + + if (isRidge && this.rng.chance(0.4)) { + // Place Sandbag on top (y+1) + this.grid.setCell(x, y + 1, z, 12); + } + } + } + } +} diff --git a/src/generation/CrystalSpiresGenerator.js b/src/generation/CrystalSpiresGenerator.js index 06bc8a7..319655d 100644 --- a/src/generation/CrystalSpiresGenerator.js +++ b/src/generation/CrystalSpiresGenerator.js @@ -1,143 +1,475 @@ import { BaseGenerator } from "./BaseGenerator.js"; +import { CrystalSpiresTextureGenerator } from "./textures/CrystalSpiresTextureGenerator.js"; /** * Generates the "Crystal Spires" biome. - * Focus: High verticality, floating islands, and void hazards. + * Structure: Distinct vertical pillars with attached platform discs. */ export class CrystalSpiresGenerator extends BaseGenerator { - /** - * @param {number} spireCount - Number of main vertical pillars. - * @param {number} islandCount - Number of floating platforms. - */ - generate(spireCount = 3, islandCount = 8) { - this.grid.fill(0); // Start with Void (Sky) - - // 1. Generate Main Spires (Vertical Columns) - const spires = []; - for (let i = 0; i < spireCount; i++) { - const x = this.rng.rangeInt(4, this.width - 5); - const z = this.rng.rangeInt(4, this.depth - 5); - const radius = this.rng.rangeInt(2, 4); - - this.buildSpire(x, z, radius); - spires.push({ x, z, radius }); - } - - // 2. Generate Floating Islands - const islands = []; - for (let i = 0; i < islandCount; i++) { - const w = this.rng.rangeInt(2, 4); - const d = this.rng.rangeInt(2, 4); - const x = this.rng.rangeInt(1, this.width - w - 1); - const z = this.rng.rangeInt(1, this.depth - d - 1); - const y = this.rng.rangeInt(2, this.height - 3); // Keep away from very bottom/top - - const island = { x, y, z, w, d }; - - // Avoid intersecting main spires too heavily (simple check) - this.buildPlatform(island); - islands.push(island); - } - - // 3. Connect Islands (Light Bridges) - // Simple strategy: Connect every island to the nearest Spire or other Island - for (const island of islands) { - const islandCenter = this.getPlatformCenter(island); - - // Find nearest Spire - let nearestSpireDist = Infinity; - let targetSpire = null; - - for (const spire of spires) { - // Approximate spire center at island height - const dist = this.dist2D( - islandCenter.x, - islandCenter.z, - spire.x, - spire.z - ); - if (dist < nearestSpireDist) { - nearestSpireDist = dist; - targetSpire = { x: spire.x, y: islandCenter.y, z: spire.z }; - } - } - - // Build Bridge to Spire - if (targetSpire) { - this.buildBridge(islandCenter, targetSpire); - } - } + constructor(grid, seed) { + super(grid, seed); + this.generatedAssets = { + palette: {}, + spawnZones: { player: [], enemy: [] }, + }; + this.preloadAssets(); } - buildSpire(centerX, centerZ, radius) { - // Wiggle the spire as it goes up - let currentX = centerX; - let currentZ = centerZ; + preloadAssets() { + const texGen = new CrystalSpiresTextureGenerator(this.seed); - for (let y = 0; y < this.height; y++) { - // Slight drift per layer - if (this.rng.chance(0.3)) currentX += this.rng.rangeInt(-1, 1); - if (this.rng.chance(0.3)) currentZ += this.rng.rangeInt(-1, 1); + // ID 1: Marble Pillar + this.generatedAssets.palette[1] = texGen.generateMarble(64); - // Draw Circle - for (let x = currentX - radius; x <= currentX + radius; x++) { - for (let z = currentZ - radius; z <= currentZ + radius; z++) { - if (this.dist2D(x, z, currentX, currentZ) <= radius) { - // Core is ID 1 (Stone/Marble), Edges might be ID 15 (Crystal) - const id = this.rng.chance(0.2) ? 15 : 1; - this.grid.setCell(x, y, z, id); + // ID 15: Crystal Scatter + this.generatedAssets.palette[15] = texGen.generateCrystal(64); + + // ID 20: Bridge + this.generatedAssets.palette[20] = texGen.generateBridge(64); + } + + /** + * @param {number} spireCount - Number of main vertical pillars. + * @param {number} numFloors - Number of vertical levels (platforms). + * @param {number} floorSpacing - Height between platforms. + */ + generate(spireCount = 4, numFloors = 3, floorSpacing = 6) { + this.grid.fill(0); // Start with Void (Sky) + + // Track spires for connections + // Each spire: { x, z, radius, platforms: [{y, r}] } + const spires = []; + + // 1. Generate Main Spires (Vertical Columns) + // Distribute them somewhat evenly or randomly but avoiding overlap + for (let i = 0; i < spireCount; i++) { + let x, z, valid; + let attempts = 0; + const radius = this.rng.rangeInt(2, 3); // Thinner pillars + + // Try to find a spot not too close to others + do { + valid = true; + x = this.rng.rangeInt(5, this.width - 6); + z = this.rng.rangeInt(5, this.depth - 6); + + for (const s of spires) { + if (this.dist2D(x, z, s.x, s.z) < 10) valid = false; + } + attempts++; + } while (!valid && attempts < 10); + + if (valid) { + this.buildPillar(x, z, radius, this.height); + // Platforms generated later in a global pass + spires.push({ x, z, radius, platforms: [] }); + } + } + + // 2. Generate Platforms (Sparse Levels) + // Create global levels to ensure vertical progression + for (let f = 0; f < numFloors; f++) { + const y = 4 + f * floorSpacing; + if (y >= this.height - 3) break; + + // Pick 1-2 random spires for this level + const levelSpires = []; + const count = 1; // Strict 1 platform per level as requested + // Force at least 1, max 2. + + // Shuffle spires to pick random ones + const shuffled = [...spires].sort(() => this.rng.next() - 0.5); + + for (let i = 0; i < Math.min(count, shuffled.length); i++) { + const s = shuffled[i]; + const discRadius = this.rng.rangeInt(4, 7); + this.buildDisc(s.x, y, s.z, discRadius); + s.platforms.push({ + x: s.x, + y, + z: s.z, + radius: discRadius, + connections: [], + }); + } + } + + // 3. Connect Spires (Bridges) + // Connect platforms at similar heights + this.connectSpires(spires); + + // 4. Define Spawn Zones + // Player starts at the BOTTOM, Enemy at the TOP + const allPlatforms = []; + for (const s of spires) { + for (const p of s.platforms) { + allPlatforms.push(p); + } + } + + if (allPlatforms.length > 0) { + // Sort by Height (Y) + allPlatforms.sort((a, b) => a.y - b.y); + + const playerPlat = allPlatforms[0]; + const enemyPlat = allPlatforms[allPlatforms.length - 1]; + + this.markSpawnZone(playerPlat, "player"); + this.markSpawnZone(enemyPlat, "enemy"); + } + + // 5. Scatter Cover (Post-generation) + // ID 15 = Crystal formations + // 3% Density (Clusters are larger so lower density) + this.scatterCrystalClusters(15, 0.03); + } + + buildPillar(centerX, centerZ, radius, height) { + // Clean, straight pillars as per Biome Spec + for (let y = 0; y < height; y++) { + for (let x = centerX - radius; x <= centerX + radius; x++) { + for (let z = centerZ - radius; z <= centerZ + radius; z++) { + if (this.dist2D(x, z, centerX, centerZ) <= radius) { + // ID 1 (Stone) center + this.grid.setCell(x, y, z, 1); } } } } } - buildPlatform(rect) { - for (let x = rect.x; x < rect.x + rect.w; x++) { - for (let z = rect.z; z < rect.z + rect.d; z++) { - // Build solid platform - this.grid.setCell(x, rect.y, z, 1); - // Ensure headroom - this.grid.setCell(x, rect.y + 1, z, 0); - this.grid.setCell(x, rect.y + 2, z, 0); + buildDisc(centerX, y, centerZ, radius) { + for (let x = centerX - radius; x <= centerX + radius; x++) { + for (let z = centerZ - radius; z <= centerZ + radius; z++) { + // Circular disc + if (this.dist2D(x, z, centerX, centerZ) <= radius) { + this.grid.setCell(x, y, z, 1); // Floor + // Clear headroom? + this.grid.setCell(x, y + 1, z, 0); + this.grid.setCell(x, y + 2, z, 0); + this.grid.setCell(x, y + 3, z, 0); + } } } } + connectSpires(spires) { + const connectedPairs = new Set(); + + // For each spire, try to connect to *multiple* nearest neighbors + for (let i = 0; i < spires.length; i++) { + const sA = spires[i]; + + // Find all neighbors + const neighbors = []; + for (let j = 0; j < spires.length; j++) { + if (i === j) continue; + const dist = this.dist2D(sA.x, sA.z, spires[j].x, spires[j].z); + neighbors.push({ spire: spires[j], dist, index: j }); + } + neighbors.sort((a, b) => a.dist - b.dist); + + // Connect to closest neighbors until we have 2 connections (or run out) + // Connect to closest neighbors until we have 2 distinct neighbor Spires connected + let connectedNeighbors = 0; + let backupBridge = null; // Store crowded connection as fallback + + for (let n = 0; n < neighbors.length; n++) { + // Stop if we have connected to enough neighbor SPIRES (not bridges) + if (connectedNeighbors >= 2) break; + + const nObj = neighbors[n]; + const sB = nObj.spire; + let bridgesToThisNeighbor = 0; + + // Redundancy: Allow bidirectional checks to ensure connectivity even if one side considers it crowded/skipped. + // Overlapping bridges (ID 20) are harmless. + // const pairId = [i, nObj.index].sort().join("-"); + // if (connectedPairs.has(pairId)) continue; + // connectedPairs.add(pairId); + + // Connect ALL valid platform pairs between these spires + // (Subject to spacing constraints) + for (const pA of sA.platforms) { + for (const pB of sB.platforms) { + // Limit: Cap unlimited bridges per pair to prevent chaotic webs, but allow enough for verticality. + if (bridgesToThisNeighbor >= 5) break; + + const yd = Math.abs(pB.y - pA.y); + const dist2d = Math.sqrt( + Math.pow(pB.x - pA.x, 2) + Math.pow(pB.z - pA.z, 2) + ); + + // dist2d is center-to-center. rimDist is edge-to-edge. + const rimDist = dist2d - pA.radius - pB.radius; + + // Requirements: + // 1. Gap > 1.0 (Prevent overlapping meshes, but allow short steep bridges) + // Requirements: + // 1. Gap > 1.0 OR Vertical Diff > 3.0 (Allow vertical shafts/ladders even if horizontally close) + // 2. Vertical Diff <= 12 (Reach) + // 3. Slope <= 3.0 (Steep but walkable stairs for short distances) + + // Note: If Gap is small (e.g. 0.5) but Yd is large (e.g. 10), the total distance is large enough (>2.0) for the failsafe. + // We only want to prevent "overlapping touches" where Gap~0 and Yd~0. + if ( + (rimDist > 1.0 || yd > 3.0) && + yd <= 12 && + yd <= Math.max(rimDist, 2) * 3.0 + ) { + // Calculate Edge Points + const dx_direct = pB.x - pA.x; + const dz_direct = pB.z - pA.z; + + const dist = Math.sqrt( + dx_direct * dx_direct + dz_direct * dz_direct + ); + if (dist2d > 0) { + const nx = dx_direct / dist2d; + const nz = dz_direct / dist2d; + // Dynamic Logic: + // 1. If touching (rimDist < 2.0), use TANGENT (90 deg) to build on the side (Spiral). + // 2. If spaced, use DIRECT (0 deg) to build across the gap (Bridge). + const isTouching = rimDist < 2.0; + + let startPt, endPt; + // Spiral Mode (Touching): Use 0.0 (Exact Rim) to avoid clipping under the floor. + // Direct Mode (Spaced): Use 1.5 (Internal) for solid floor connection. + const penetration = isTouching ? 0.0 : 1.5; + + if (isTouching) { + // Spiral Mode: Use Tangent Vector (-nz, nx) + // Try "Right" Tangent first + const tx = -nz; + const tz = nx; + + // For Spiral, both platforms connect on the same "side" (e.g. North face of both). + startPt = this.getUncrowdedRimPoint(pA, tx, tz, penetration); + endPt = this.getUncrowdedRimPoint(pB, tx, tz, penetration); // Same vector for B! + + // If blocked, try "Left" Tangent + if (!startPt || !endPt) { + startPt = this.getUncrowdedRimPoint( + pA, + -tx, + -tz, + penetration + ); + endPt = this.getUncrowdedRimPoint( + pB, + -tx, + -tz, + penetration + ); + } + } else { + // Direct Mode: Use Normal Vector (nx, nz) + startPt = this.getUncrowdedRimPoint(pA, nx, nz, penetration); + endPt = this.getUncrowdedRimPoint(pB, -nx, -nz, penetration); // Invert for B + } + + if (startPt && endPt) { + pA.connections.push(startPt); + pB.connections.push(endPt); + + this.buildBridge( + { x: startPt.x, y: pA.y, z: startPt.z }, + { x: endPt.x, y: pB.y, z: endPt.z } + ); + bridgesToThisNeighbor++; + } else if (!backupBridge && isTouching) { + // Forced Spiral Backup + const tx = -nz; + const tz = nx; + const idealStartX = pA.x + tx * (pA.radius - penetration); + const idealStartZ = pA.z + tz * (pA.radius - penetration); + const idealEndX = pB.x + tx * (pB.radius - penetration); + const idealEndZ = pB.z + tz * (pB.radius - penetration); + + backupBridge = { + start: { x: idealStartX, y: pA.y, z: idealStartZ }, + end: { x: idealEndX, y: pB.y, z: idealEndZ }, + pA, + bestP: pB, + startPt: { x: idealStartX, z: idealStartZ }, + endPt: { x: idealEndX, z: idealEndZ }, + }; + } else if (!backupBridge) { + // Forced Direct Backup + const idealStartX = pA.x + nx * (pA.radius - penetration); + const idealStartZ = pA.z + nz * (pA.radius - penetration); + const idealEndX = pB.x - nx * (pB.radius - penetration); + const idealEndZ = pB.z - nz * (pB.radius - penetration); + + backupBridge = { + start: { x: idealStartX, y: pA.y, z: idealStartZ }, + end: { x: idealEndX, y: pB.y, z: idealEndZ }, + pA, + bestP: pB, + startPt: { x: idealStartX, z: idealStartZ }, + endPt: { x: idealEndX, z: idealEndZ }, + }; + } + } + } + } + } + + // If we built ANY bridges to this neighbor, count it + if (bridgesToThisNeighbor > 0) { + connectedNeighbors++; + } + } + + // Fallback: If we haven't connected to enough NEIGHBORS (2) and have a valid (but crowded) backup, use it. + if (connectedNeighbors < 2 && backupBridge) { + backupBridge.pA.connections.push(backupBridge.startPt); + backupBridge.bestP.connections.push(backupBridge.endPt); + this.buildBridge(backupBridge.start, backupBridge.end); + } + } + } + + /** + * Tries to find a rim point near the ideal normal (nx, nz) that isn't crowded. + * Scans +/- 50 degrees. + */ + getUncrowdedRimPoint(platform, nx, nz, penetration = 1.5) { + // Angles to try: 0, +20, -20, +40, -40 + const angles = [0, 0.35, -0.35, 0.7, -0.7]; // Radians (approx 20, 40 deg) + + const idealAngle = Math.atan2(nz, nx); + + for (const offset of angles) { + const angle = idealAngle + offset; + const rX = Math.cos(angle); + const rZ = Math.sin(angle); + + const px = platform.x + rX * (platform.radius - penetration); + const pz = platform.z + rZ * (platform.radius - penetration); + + let crowded = false; + for (const c of platform.connections) { + if (this.dist2D(px, pz, c.x, c.z) < 2.0) { + // Reduced spacing to 2.0 allows tighter hubs + crowded = true; + break; + } + } + + if (!crowded) { + return { x: px, z: pz }; + } + } + return null; + } + buildBridge(start, end) { - const dist = this.dist2D(start.x, start.z, end.x, end.z); - const steps = Math.ceil(dist); + // Use 3D distance + const dist = Math.sqrt( + Math.pow(end.x - start.x, 2) + + Math.pow(end.y - start.y, 2) + + Math.pow(end.z - start.z, 2) + ); + + // FAILSAFE: Reject bridges that are too short (touching/overlapping) + if (dist < 2.0) return; + + // Supersample for solid path + const steps = Math.ceil(dist * 3); const stepX = (end.x - start.x) / steps; + const stepY = (end.y - start.y) / steps; // Slope connection const stepZ = (end.z - start.z) / steps; let currX = start.x; + let currY = start.y; let currZ = start.z; - const y = start.y; // Flat bridge for now for (let i = 0; i <= steps; i++) { const tx = Math.round(currX); + const ty = Math.round(currY); const tz = Math.round(currZ); // Bridge Voxel ID 20 (Light Bridge) - if (this.grid.getCell(tx, y, tz) === 0) { - this.grid.setCell(tx, y, tz, 20); + // Only overwrite air + if (this.grid.getCell(tx, ty, tz) === 0) { + this.grid.setCell(tx, ty, tz, 20); } currX += stepX; + currY += stepY; currZ += stepZ; } } - getPlatformCenter(rect) { - return { - x: Math.floor(rect.x + rect.w / 2), - y: rect.y, - z: Math.floor(rect.z + rect.d / 2), - }; - } - dist2D(x1, z1, x2, z2) { return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(z2 - z1, 2)); } + + /** + * Scatters clusters of crystal blocks to create jagged cover. + */ + scatterCrystalClusters(id, density) { + const validSpots = []; + + // 1. Find valid floor spots + 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 - 3; y++) { + // -3 to allow for height + const currentId = this.grid.getCell(x, y, z); + const belowId = this.grid.getCell(x, y - 1, z); + + if (currentId === 0 && belowId !== 0 && belowId !== 20) { + validSpots.push({ x, y, z }); + } + } + } + } + + // 2. Place Clusters + for (const pos of validSpots) { + if (this.rng.chance(density)) { + // Center + this.grid.setCell(pos.x, pos.y, pos.z, id); + + // 1-3 Extra Blocks for cluster feel + const extras = this.rng.rangeInt(1, 4); + for (let i = 0; i < extras; i++) { + // Random adjacent offset (including up) + const ox = this.rng.rangeInt(-1, 1); + const oy = this.rng.rangeInt(0, 1); // Tend slightly upwards + const oz = this.rng.rangeInt(-1, 1); + + // Don't overwrite if not air + if (this.grid.getCell(pos.x + ox, pos.y + oy, pos.z + oz) === 0) { + this.grid.setCell(pos.x + ox, pos.y + oy, pos.z + oz, id); + } + } + } + } + } + + markSpawnZone(platform, type) { + // Scan the disc area for valid standing spots + const r = platform.radius; + const y = platform.y; + + for (let x = platform.x - r; x <= platform.x + r; x++) { + for (let z = platform.z - r; z <= platform.z + r; z++) { + // Must be within radius + if (this.dist2D(x, z, platform.x, platform.z) > r - 1) continue; // Stay slightly away from edge + + // Check for solid floor and empty air + const floorId = this.grid.getCell(x, y, z); + const air1 = this.grid.getCell(x, y + 1, z); + // We know we built floor at y, cleared 1,2,3 + + if (floorId !== 0 && air1 === 0) { + this.generatedAssets.spawnZones[type].push({ x, y: y + 1, z }); + } + } + } + } } diff --git a/src/generation/RuinGenerator.js b/src/generation/RustingWastesGenerator.js similarity index 97% rename from src/generation/RuinGenerator.js rename to src/generation/RustingWastesGenerator.js index 3c7b4dd..1ff50cc 100644 --- a/src/generation/RuinGenerator.js +++ b/src/generation/RustingWastesGenerator.js @@ -8,12 +8,12 @@ import { RustedWallTextureGenerator } from "./textures/RustedWallTextureGenerato import { RustedFloorTextureGenerator } from "./textures/RustedFloorTextureGenerator.js"; /** - * Generates structured rooms and corridors. + * Generates structured rooms and corridors for the Rusting Wastes biome. * Uses an "Additive" approach (Building in Void) to ensure good visibility. * Integrated with Procedural Texture Palette. * @class */ -export class RuinGenerator extends BaseGenerator { +export class RustingWastesGenerator extends BaseGenerator { /** * @param {import("../grid/VoxelGrid.js").VoxelGrid} grid - Voxel grid to generate into * @param {number} seed - Random seed diff --git a/src/generation/VoidSeepDepthsGenerator.js b/src/generation/VoidSeepDepthsGenerator.js new file mode 100644 index 0000000..fa80599 --- /dev/null +++ b/src/generation/VoidSeepDepthsGenerator.js @@ -0,0 +1,200 @@ +import { BaseGenerator } from "./BaseGenerator.js"; +import { VoidSeepTextureGenerator } from "./textures/VoidSeepTextureGenerator.js"; + +/** + * Generates the "Void Seep Depths" biome. + * Structure: Circular Arena with "Void Rifts" (Holes) and corruption. + * IDs: + * - 50: Obsidian Floor + * - 51: Corruption Vein (Floor) + * - 52: Obsidian Pillar (Wall/Obstacle) + * - 55: Pulsing Tumor (Cover) + */ +export class VoidSeepDepthsGenerator extends BaseGenerator { + constructor(grid, seed) { + super(grid, seed); + this.generatedAssets = { + palette: {}, + spawnZones: { player: [], enemy: [] }, + }; + this.preloadAssets(); + } + + preloadAssets() { + if (typeof OffscreenCanvas === "undefined") { + console.warn( + "OffscreenCanvas not available. Skipping texture generation." + ); + return; + } + const texGen = new VoidSeepTextureGenerator(this.seed); + + // ID 50: Obsidian Floor + this.generatedAssets.palette[50] = texGen.generateObsidian(64); + + // ID 51: Corruption Vein (Hot pink/Purple) + this.generatedAssets.palette[51] = texGen.generateCorruption(64); + + // ID 52: Obsidian Pillar (Same as floor but vertical usage) + this.generatedAssets.palette[52] = this.generatedAssets.palette[50]; // Reuse obsidian + + // ID 53: Glowing Obsidian Pillar (Obsidian with Veins) + // Reuse corruption texture for strong glow + this.generatedAssets.palette[53] = this.generatedAssets.palette[51]; + + // ID 55: Pulsing Tumor (Organic/Gross) + // Reuse corruption for now or tweak + this.generatedAssets.palette[55] = texGen.generateCorruption(64); + } + + /** + * Generates the level. + * @param {number} difficulty - affecting hazard density + */ + generate(difficulty = 1) { + this.grid.fill(0); // Sky + + const cx = Math.floor(this.width / 2); + const cz = Math.floor(this.depth / 2); + const arenaRadius = Math.min(cx, cz) - 2; + const floorY = 5; + + // 1. Build Base Arena (Obsidian Disc) + this.buildDisc(cx, floorY, cz, arenaRadius, 50); + + // 2. Carve Void Rifts (Holes) + // Avoid Center and "Safe Zones" (North/South spawn points) + const riftCount = 5 + difficulty * 2; + for (let i = 0; i < riftCount; i++) { + const angle = this.rng.next() * Math.PI * 2; + const r = this.rng.range(5, arenaRadius - 2); // Avoid very edge + const rx = Math.floor(cx + Math.cos(angle) * r); + const rz = Math.floor(cz + Math.sin(angle) * r); + + // CHECK SAFE ZONES + // Safe Zone 1: North (Z min) + if (this.dist2D(rx, rz, cx, 2) < 6) continue; + // Safe Zone 2: South (Z max) + if (this.dist2D(rx, rz, cx, this.depth - 2) < 6) continue; + // Center Safe Zone (optional, good for king of hill) + if (this.dist2D(rx, rz, cx, cz) < 5) continue; + + const radius = this.rng.rangeInt(2, 4); + this.carveCylinder(rx, floorY - 1, rz, radius, 3, 0); // Carve air (0) + } + + // 3. Corruption Veins + // Replace some floor with corruption + this.scatterVeins(51, 0.1); // 10% corruption + + // 4. Vertical Cover (Obsidian Spikes) + const pillarCount = 8; + for (let i = 0; i < pillarCount; i++) { + const angle = this.rng.next() * Math.PI * 2; + const r = this.rng.range(5, arenaRadius - 3); + const px = Math.floor(cx + Math.cos(angle) * r); + const pz = Math.floor(cz + Math.sin(angle) * r); + + // Avoid void (don't build on air) + if (this.grid.getCell(px, floorY, pz) === 0) continue; + + const h = this.rng.rangeInt(3, 8); + // 30% Chance to be a "Glowing Pillar" (ID 53) + const pId = this.rng.chance(0.3) ? 53 : 52; + this.buildPillar(px, floorY, pz, h, pId); + } + + // 5. Scatter Tumors (Low Cover) + this.scatterCover(55, 0.05); // 5% density on valid floor + + // 6. Define Spawn Zones + // Player: North (Z min), Enemy: South (Z max) + // We scan the "Safe Zones" we protected from rifts earlier. + const safeRadius = 6; + + // Player Zone (North: cz - r to cz - r + safe) -> Wait, Z=0 is North? + // In generate(): Safe Zone 1: North (Z min) -> check (cx, 2) + // Safe Zone 2: South (Z max) -> check (cx, depth-2) + + this.markSafeZoneSpawn(cx, 2, safeRadius, "player"); + this.markSafeZoneSpawn(cx, this.depth - 2, safeRadius, "enemy"); + } + + markSafeZoneSpawn(cx, cz, r, type) { + for (let x = cx - r; x <= cx + r; x++) { + for (let z = cz - r; z <= cz + r; z++) { + if (this.dist2D(x, z, cx, cz) <= r) { + // Check if valid floor (ID 50/51/etc) and Air above + // Floor is at y=5 + const fy = 5; + if ( + this.grid.isValidBounds(x, fy, z) && + this.grid.getCell(x, fy, z) !== 0 && + this.grid.getCell(x, fy + 1, z) === 0 + ) { + this.generatedAssets.spawnZones[type].push({ x, y: fy + 1, z }); + } + } + } + } + } + + buildDisc(cx, y, cz, r, id) { + for (let x = cx - r; x <= cx + r; x++) { + for (let z = cz - r; z <= cz + r; z++) { + if (this.dist2D(x, z, cx, cz) <= r) { + // Make it 2 blocks thick + this.grid.setCell(x, y, z, id); + this.grid.setCell(x, y - 1, z, id); + } + } + } + } + + carveCylinder(cx, bottomY, cz, r, height, id) { + for (let y = bottomY; y < bottomY + height; y++) { + for (let x = cx - r; x <= cx + r; x++) { + for (let z = cz - r; z <= cz + r; z++) { + if (this.dist2D(x, z, cx, cz) <= r) { + if (this.grid.isValidBounds(x, y, z)) { + this.grid.setCell(x, y, z, id); + } + } + } + } + } + } + + buildPillar(cx, bottomY, cz, height, id) { + // Simple 1x1 or cross + for (let y = bottomY + 1; y <= bottomY + height; y++) { + this.grid.setCell(cx, y, cz, id); + // Make base slightly thicker + if (y === bottomY + 1) { + this.grid.setCell(cx + 1, y, cz, id); + this.grid.setCell(cx - 1, y, cz, id); + this.grid.setCell(cx, y, cz + 1, id); + this.grid.setCell(cx, y, cz - 1, id); + } + } + } + + scatterVeins(id, density) { + for (let x = 0; x < this.width; x++) { + for (let z = 0; z < this.depth; z++) { + // Only replace valid floor (50) + const current = this.grid.getCell(x, 5, z); // Assuming flat floor at 5 + if (current === 50 && this.rng.chance(density)) { + this.grid.setCell(x, 5, z, id); + // Spread slightly + if (this.rng.chance(0.5)) this.grid.setCell(x + 1, 5, z, id); + if (this.rng.chance(0.5)) this.grid.setCell(x, 5, z + 1, id); + } + } + } + } + + dist2D(x1, z1, x2, z2) { + return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(z2 - z1, 2)); + } +} diff --git a/src/generation/test-void-seep-light.js b/src/generation/test-void-seep-light.js new file mode 100644 index 0000000..8981e59 --- /dev/null +++ b/src/generation/test-void-seep-light.js @@ -0,0 +1,47 @@ + +import { VoxelGrid } from "../grid/VoxelGrid.js"; +import { VoidSeepDepthsGenerator } from "./VoidSeepDepthsGenerator.js"; + +console.log("Starting Void Seep Lighting Verification..."); + +try { + const grid = new VoxelGrid(40, 40, 40); + const generator = new VoidSeepDepthsGenerator(grid, 12345); + + // Stub OffscreenCanvas if running in Node (already handled in Generator but good to be safe) + if (typeof global.OffscreenCanvas === 'undefined') { + global.OffscreenCanvas = class { + constructor() {} + getContext() { return { fillRect:()=>{}, fillStyle:"", strokeStyle:"", beginPath:()=>{}, moveTo:()=>{}, lineTo:()=>{}, stroke:()=>{}, transferToImageBitmap:()=>({}) }; } + }; + } + + console.log("Generating..."); + generator.generate(1); + + let glowingPillarCount = 0; + + for(let x=0; x<40; x++) { + for(let y=0; y<40; y++) { + for(let z=0; z<40; z++) { + const id = grid.getCell(x, y, z); + if (id === 53) glowingPillarCount++; + } + } + } + + console.log(`Generation Complete.`); + console.log(`Glowing Pillar Voxels: ${glowingPillarCount}`); + + if (glowingPillarCount === 0) { + console.warn("No Glowing Pillars found! (Might be bad luck or logic error, but 30% chance should produce some)"); + // Don't throw, just warn, as it is procedural. + } else { + console.log("Verified: Glowing Pillars exist."); + } + + console.log("Lighting Verification Passed!"); +} catch (e) { + console.error("Verification Failed:", e); + process.exit(1); +} diff --git a/src/generation/textures/BioluminescentCaveWallTextureGenerator.js b/src/generation/textures/BioluminescentCaveWallTextureGenerator.js index ca53743..91b5c03 100644 --- a/src/generation/textures/BioluminescentCaveWallTextureGenerator.js +++ b/src/generation/textures/BioluminescentCaveWallTextureGenerator.js @@ -15,9 +15,10 @@ export class BioluminescentCaveWallTextureGenerator { * Returns an object containing diffuse, emissive, normal, roughness, and bump maps. * @param {number} size - Resolution * @param {number|null} variantSeed - Optional seed for center variation + * @param {boolean} enableVeins - Whether to generate glowing roots/crystals * @returns {{diffuse: OffscreenCanvas, emissive: OffscreenCanvas, normal: OffscreenCanvas, roughness: OffscreenCanvas, bump: OffscreenCanvas}} */ - generateWall(size = 64, variantSeed = null) { + generateWall(size = 64, variantSeed = null, enableVeins = true) { // Prepare Canvases const maps = { diffuse: new OffscreenCanvas(size, size), @@ -105,41 +106,67 @@ export class BioluminescentCaveWallTextureGenerator { // Height Default (Based on rock noise) let heightVal = rockNoise * 0.5; - // 2. Crystal Veins (Ridge Noise) - // Sharpened ridge noise for clearer vein definition - let rawVeinNoise = Math.abs(noiseFn(nx * 4 + 50, ny * 4 + 50)); - let veinNoise = 1.0 - Math.pow(rawVeinNoise, 0.5); // Sharpen curve + // Only generate veins if enabled + if (enableVeins) { + // 2. Bioluminescent Roots (Thick, organic paths) + // Use lower frequency ridge noise, but invert it differently to make it "thick" + // Math.abs(noise) is 0 at the "vein", 1 at the peak. + let rootNoiseRaw = Math.abs(noiseFn(nx * 3 + 50, ny * 3 + 50)); - // Threshold: Only draw where the 'ridge' is very sharp - if (veinNoise > 0.9) { - let colorSelector = noiseFn(nx * 2 + 100, ny * 2 + 100); - let crystalColor = - colorSelector > 0 ? COLOR_CRYSTAL_PURPLE : COLOR_CRYSTAL_CYAN; + // We want a thick vein, so we take values close to 0. + // 1.0 - rootNoiseRaw gives 1.0 at center, 0.0 at peaks. + // We threshold it to make it a defined "root" rather than a gradient. + let rootSignal = 1.0 - rootNoiseRaw; - // Blend crystal color (Emissive style - bright) into diffuse - r = this.lerp(r, crystalColor.r, 0.9); - g = this.lerp(g, crystalColor.g, 0.9); - b = this.lerp(b, crystalColor.b, 0.9); + // Make it distinct but SPARSER (User Feedback: "mostly veins" previously) + // Increased threshold from 0.85 to 0.93 to reduce coverage significantly + if (rootSignal > 0.93) { + // Boost standard rock color + r = this.lerp(r, COLOR_CRYSTAL_CYAN.r, 0.7); + g = this.lerp(g, COLOR_CRYSTAL_CYAN.g, 0.7); + b = this.lerp(b, COLOR_CRYSTAL_CYAN.b, 0.7); - // Set Emissive Map Color (Pure crystal color) - er = crystalColor.r; - eg = crystalColor.g; - eb = crystalColor.b; + // Set Emissive Map Color (Pure crystal color) + er = COLOR_CRYSTAL_CYAN.r; + eg = COLOR_CRYSTAL_CYAN.g; + eb = COLOR_CRYSTAL_CYAN.b; - // Crystals are smooth - roughVal = 20; - // Crystals protrude slightly - heightVal += 0.4; + // Crystals are smooth + roughVal = 50; + // Crystals protrude slightly + heightVal += 0.3; + } + + // 3. Crystal Clusters (Patchy spots) + // High frequency noise + let clusterNoise = noiseFn(nx * 12 + 200, ny * 12 + 200); + // Reduced density (0.6 -> 0.75) + if (clusterNoise > 0.75) { + let clusterColor = COLOR_CRYSTAL_PURPLE; + + r = this.lerp(r, clusterColor.r, 0.9); + g = this.lerp(g, clusterColor.g, 0.9); + b = this.lerp(b, clusterColor.b, 0.9); + + er = clusterColor.r; + eg = clusterColor.g; + eb = clusterColor.b; + + roughVal = 20; // Glassy + heightVal += 0.5; // Sticking out + } } - // 3. Deep Cracks (Darker Ridge Noise) - let crackNoise = 1.0 - Math.abs(noiseFn(nx * 10 + 200, ny * 10 + 200)); - if (crackNoise > 0.85 && veinNoise <= 0.9) { - r *= 0.3; - g *= 0.3; - b *= 0.3; // Darker cracks - heightVal -= 0.5; // Deeper cracks - roughVal = 255; // Very rough + // 4. Deep Cracks (Darker Ridge Noise) + // Use a different frequency to avoid perfectly aligning with roots + let crackNoise = 1.0 - Math.abs(noiseFn(nx * 8 + 200, ny * 8 + 200)); + // Only if not glowing + if (crackNoise > 0.8 && er === 0) { + r *= 0.2; + g *= 0.2; + b *= 0.2; + heightVal -= 0.6; + roughVal = 255; } // Store height for Normal Map pass diff --git a/src/generation/textures/CrystalSpiresTextureGenerator.js b/src/generation/textures/CrystalSpiresTextureGenerator.js new file mode 100644 index 0000000..dcd60dc --- /dev/null +++ b/src/generation/textures/CrystalSpiresTextureGenerator.js @@ -0,0 +1,245 @@ +import { SimplexNoise } from "../../utils/SimplexNoise.js"; + +/** + * Generates textures for the Crystal Spires biome. + * Focus: Clean, geometric, and high-contrast looks. + */ +export class CrystalSpiresTextureGenerator { + constructor(seed = 12345) { + this.seed = seed; + this.simplex = new SimplexNoise(seed); + } + + /** + * Helper: Fractal Brownian Motion + */ + fbm(x, y, octaves, noiseFn = null) { + let val = 0; + let freq = 1; + let amp = 0.5; + const n = noiseFn || ((nx, ny) => this.simplex.noise2D(nx, ny)); + + for (let i = 0; i < octaves; i++) { + val += n(x * freq, y * freq) * amp; + freq *= 2; + amp *= 0.5; + } + return val; // -1 to 1 approx + } + + lerp(a, b, t) { + return a + (b - a) * t; + } + + /** + * Generates a Smooth Marble Texture (for Pillars/Platforms) + * ID: 1 + */ + generateMarble(size = 64) { + const maps = this.createMaps(size); + const { ctxs, datas } = maps; + + const COLOR_BASE = { r: 250, g: 250, b: 255 }; // Almost pure white + const COLOR_VEIN = { r: 200, g: 200, b: 210 }; // Very faint light grey veins + + for (let x = 0; x < size; x++) { + for (let y = 0; y < size; y++) { + const nx = x / size; + const ny = y / size; + const index = (x + y * size) * 4; + + // Warped noise for marble veins + const warpX = this.fbm(nx * 4, ny * 4, 2); + const warpY = this.fbm(nx * 4 + 10, ny * 4 + 10, 2); + + let noiseVal = this.simplex.noise2D( + nx * 4 + warpX * 0.5, + ny * 4 + warpY * 0.5 + ); + noiseVal = Math.abs(noiseVal); // Sharp veins (0 at center) + noiseVal = Math.pow(noiseVal, 0.5); // Widen/Soften + + let r = this.lerp(COLOR_VEIN.r, COLOR_BASE.r, noiseVal); + let g = this.lerp(COLOR_VEIN.g, COLOR_BASE.g, noiseVal); + let b = this.lerp(COLOR_VEIN.b, COLOR_BASE.b, noiseVal); + + // Smooth surface + let rough = 20 + noiseVal * 50; + + // Emissive: none + let er = 0, + eg = 0, + eb = 0; + + // Normal/Height + let h = noiseVal; // Veins are deeper (low val) if we invert? + // noiseVal is 0 at vein center (darker). So Height 0 = Deep. + + this.setPixel(datas.diffuse, index, r, g, b, 255); + this.setPixel(datas.emissive, index, er, eg, eb, 255); + this.setPixel(datas.roughness, index, rough, rough, rough, 255); + this.setPixel(datas.bump, index, h * 255, h * 255, h * 255, 255); + + // Rough Approx Normal + this.setPixel(datas.normal, index, 128, 128, 255, 255); + } + } + + this.putImages(ctxs, datas); + return maps.canvases; + } + + /** + * Generates a Faceted Crystal Texture (for Shards) + * ID: 15 + */ + generateCrystal(size = 64) { + const maps = this.createMaps(size); + const { ctxs, datas } = maps; + + // Voronoi-like cellular noise logic (simplified manually or usually separate lib) + // We'll simulate facets with angled gradients or rigid noise + + // Biome Spec: "raw blue Aether crystal" + const COLOR_CRYSTAL = { r: 0, g: 40, b: 220 }; // Deep Blue + const COLOR_HIGHLIGHT = { r: 200, g: 220, b: 255 }; // White/Light Blue Highlight + + for (let x = 0; x < size; x++) { + for (let y = 0; y < size; y++) { + const nx = x / size; + const ny = y / size; + const index = (x + y * size) * 4; + + // Rigid noise: |noise| * scale + // Large shapes + let facet = Math.abs(this.simplex.noise2D(nx * 3, ny * 3)); + // Add smaller facets + facet += Math.abs(this.simplex.noise2D(nx * 10, ny * 10)) * 0.5; + facet = Math.min(1, facet); + + // Color + // Color (Deep Blue Base) + let r = COLOR_CRYSTAL.r; + let g = COLOR_CRYSTAL.g; + let b = COLOR_CRYSTAL.b; + + // Add Facet Highlights (White edges/centers) + // High facet values get white mix + if (facet > 0.7) { + const t = (facet - 0.7) / 0.3; // 0 to 1 + r = this.lerp(r, COLOR_HIGHLIGHT.r, t); + g = this.lerp(g, COLOR_HIGHLIGHT.g, t); + b = this.lerp(b, COLOR_HIGHLIGHT.b, t); + } else { + // Darker gradients for modulation + const shade = 0.5 + facet * 0.5; + r *= shade; + g *= shade; + b *= shade; + } + + // High Emissive + let er = r; + let eg = g; + let eb = b; + + // Very smooth + let rough = 10; + + this.setPixel(datas.diffuse, index, r, g, b, 255); + this.setPixel(datas.emissive, index, er, eg, eb, 255); + this.setPixel(datas.roughness, index, rough, rough, rough, 255); + this.setPixel( + datas.bump, + index, + facet * 255, + facet * 255, + facet * 255, + 255 + ); + this.setPixel(datas.normal, index, 128, 128, 255, 255); + } + } + + this.putImages(ctxs, datas); + return maps.canvases; + } + + /** + * Generates Holographic Bridge Texture + * ID: 20 + */ + generateBridge(size = 64) { + const maps = this.createMaps(size); + const { ctxs, datas } = maps; + + for (let x = 0; x < size; x++) { + for (let y = 0; y < size; y++) { + const index = (x + y * size) * 4; + + // Grid pattern + const gridSpace = 8; + const lineThick = 1; + const onGrid = x % gridSpace < lineThick || y % gridSpace < lineThick; + + let r = 0, + g = 0, + b = 0; + + if (onGrid) { + r = 100; + g = 200; + b = 255; + } else { + // Faint body + r = 20; + g = 40; + b = 50; + } + + const er = r; + const eg = g; + const eb = b; + + this.setPixel(datas.diffuse, index, r, g, b, 200); // Semi-transparent? + this.setPixel(datas.emissive, index, er, eg, eb, 255); + this.setPixel(datas.roughness, index, 0, 0, 0, 255); // Glossy + this.setPixel(datas.bump, index, onGrid ? 255 : 0, 0, 0, 255); + this.setPixel(datas.normal, index, 128, 128, 255, 255); + } + } + + this.putImages(ctxs, datas); + return maps.canvases; + } + + createMaps(size) { + const canvases = { + diffuse: new OffscreenCanvas(size, size), + emissive: new OffscreenCanvas(size, size), + normal: new OffscreenCanvas(size, size), + roughness: new OffscreenCanvas(size, size), + bump: new OffscreenCanvas(size, size), + }; + const ctxs = {}; + const datas = {}; + for (const k in canvases) { + ctxs[k] = canvases[k].getContext("2d"); + datas[k] = ctxs[k].createImageData(size, size); + } + return { canvases, ctxs, datas }; + } + + setPixel(imgData, index, r, g, b, a) { + imgData.data[index] = r; + imgData.data[index + 1] = g; + imgData.data[index + 2] = b; + imgData.data[index + 3] = a; + } + + putImages(ctxs, datas) { + for (const k in ctxs) { + ctxs[k].putImageData(datas[k], 0, 0); + } + } +} diff --git a/src/generation/textures/TrenchTextureGenerator.js b/src/generation/textures/TrenchTextureGenerator.js new file mode 100644 index 0000000..b791221 --- /dev/null +++ b/src/generation/textures/TrenchTextureGenerator.js @@ -0,0 +1,189 @@ +import { SimplexNoise } from "../../utils/SimplexNoise.js"; + +/** + * Procedural Texture Generator for Contested Frontier. + * Dependency Free. Uses OffscreenCanvas for worker compatibility. + */ +export class TrenchTextureGenerator { + constructor(seed) { + this.simplex = new SimplexNoise(seed); + } + + /** + * Generates the Ground Textures (Mud + Grass Patches). + * @param {number} size - Resolution (e.g. 64, 128) + * @param {number} bias - Bias for grass likelihood (-1.0 to 1.0). Negative = Muddy, Positive = Grassy. + */ + generateFloor(size = 64, variantSeed = null, bias = 0.0) { + const maps = { + diffuse: new OffscreenCanvas(size, size), + normal: new OffscreenCanvas(size, size), + roughness: new OffscreenCanvas(size, size), + bump: new OffscreenCanvas(size, size), + }; + + const ctxs = {}; + const datas = {}; + + for (const key in maps) { + ctxs[key] = maps[key].getContext("2d"); + datas[key] = ctxs[key].createImageData(size, size); + } + + let variantSimplex = null; + if (variantSeed !== null) { + variantSimplex = new SimplexNoise(variantSeed); + } + + const heightBuffer = new Float32Array(size * size); + + for (let x = 0; x < size; x++) { + for (let y = 0; y < size; y++) { + const nx = x / size; + const ny = y / size; + const index = (x + y * size) * 4; + + // Noise for Mud vs Grass distribution + const noiseFn = (freqX, freqY) => { + const val = this.simplex.noise2D(freqX, freqY); + if (variantSimplex) { + return ( + (val + variantSimplex.noise2D(freqX * 1.1, freqY * 1.1)) * 0.5 + ); + } + return val; + }; + + // Base Terrain Noise + let baseVal = this.fbm(nx * 3, ny * 3, 4, noiseFn); + + // Grass Noise (High Frequency) + // Grass appears on "higher" drier spots + let grassVal = this.fbm(nx * 10, ny * 10, 2, noiseFn); + + // Adjust threshold based on bias + // Base required baseVal > 0.2. + // With bias 0.5 (Grassy), we want easier grass -> require baseVal > 0.0 + // With bias -0.5 (Muddy), we want harder grass -> require baseVal > 0.4 + const grassThreshold = 0.2 - bias * 0.3; + + const isGrass = baseVal > grassThreshold && grassVal > -bias * 0.5; + + // Heights + let height = baseVal * 0.5 + 0.5; // 0-1 + if (isGrass) height += 0.15; // Grass sits on top + + // Mud logic: Low spots are smoother and darker + const isDeepMud = baseVal < -0.3; + if (isDeepMud) { + height *= 0.8; // Sink a bit + } + + heightBuffer[x + y * size] = height; + + // Colors + let r, g, b, roughness; + + if (isGrass) { + // Dead/Dying Grass Green/Brown + r = 60 + grassVal * 40; + g = 80 + grassVal * 40; + b = 40 + grassVal * 20; + roughness = 240; // Rough + } else if (isDeepMud) { + // Wet Mud (Darker, Saturated) + r = 50 + baseVal * 20; + g = 40 + baseVal * 20; + b = 30 + baseVal * 10; + roughness = 40; // Wet/Shiny + } else { + // Dried Dirt (Lighter Brown) + r = 90 + baseVal * 30; + g = 75 + baseVal * 30; + b = 60 + baseVal * 30; + roughness = 200; // Dry + } + + // Diffuse + datas.diffuse.data[index] = r; + datas.diffuse.data[index + 1] = g; + datas.diffuse.data[index + 2] = b; + datas.diffuse.data[index + 3] = 255; + + // Roughness + datas.roughness.data[index] = roughness; + datas.roughness.data[index + 1] = roughness; + datas.roughness.data[index + 2] = roughness; + datas.roughness.data[index + 3] = 255; + + // Bump + const bumpByte = Math.floor(height * 255); + datas.bump.data[index] = bumpByte; + datas.bump.data[index + 1] = bumpByte; + datas.bump.data[index + 2] = bumpByte; + datas.bump.data[index + 3] = 255; + } + } + + // Normal Map Calculation + this.computeNormalMap(datas.normal, heightBuffer, size); + + for (const key in maps) { + ctxs[key].putImageData(datas[key], 0, 0); + } + + return maps; + } + + /** + * Generates Wall Textures (Trench Walls - Wood/Dirt mix). + */ + generateWall(size = 64, variantSeed = null) { + // Simplified: Just use dirt texture for now, maybe add wooden planks later if needed + // For now, reusing floor generation logic but tweaking colors for side walls + // Walls are usually muddy so we pass a negative bias + return this.generateFloor(size, variantSeed, -0.5); + } + + computeNormalMap(targetData, heightBuffer, size) { + for (let x = 0; x < size; x++) { + for (let y = 0; y < size; y++) { + const index = (x + y * size) * 4; + const x0 = Math.max(0, x - 1); + const x1 = Math.min(size - 1, x + 1); + const y0 = Math.max(0, y - 1); + const y1 = Math.min(size - 1, y + 1); + + const strength = 3.0; + const dx = + (heightBuffer[x1 + y * size] - heightBuffer[x0 + y * size]) * + strength; + const dy = + (heightBuffer[x + y1 * size] - heightBuffer[x + y0 * size]) * + strength; + + let nx = -dx; + let ny = -dy; + let nz = 1.0; + + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + targetData.data[index] = ((nx / len) * 0.5 + 0.5) * 255; + targetData.data[index + 1] = ((ny / len) * 0.5 + 0.5) * 255; + targetData.data[index + 2] = ((nz / len) * 0.5 + 0.5) * 255; + targetData.data[index + 3] = 255; + } + } + } + + fbm(x, y, octaves, noiseFn) { + let value = 0; + let amp = 0.5; + let freq = 1.0; + for (let i = 0; i < octaves; i++) { + value += noiseFn(x * freq, y * freq) * amp; + freq *= 2; + amp *= 0.5; + } + return value; + } +} diff --git a/src/generation/textures/VoidSeepTextureGenerator.js b/src/generation/textures/VoidSeepTextureGenerator.js new file mode 100644 index 0000000..a4dd1af --- /dev/null +++ b/src/generation/textures/VoidSeepTextureGenerator.js @@ -0,0 +1,151 @@ +/** + * Generates textures for the Void Seep Depths biome. + * Style: Obsidian black stone, neon violet corruption, glitchy visual effects. + */ +export class VoidSeepTextureGenerator { + constructor(seed) { + this.seed = seed; + } + + /** + * Generates a glossy, dark obsidian texture. + * @param {number} size - Texture size (e.g., 64) + * @returns {Object} { diffuse, normal, roughness, bump } + */ + generateObsidian(size = 64) { + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext("2d"); + + // base: Very dark grey/black + ctx.fillStyle = "#111111"; // Near black + ctx.fillRect(0, 0, size, size); + + // Subtle noise for stone surface + this.noisePass(ctx, size, 0.05, 0.1); + + // Glossy finish (handled by roughness map mostly) but let's add some faint veins + ctx.strokeStyle = "#222222"; + ctx.lineWidth = 1; + for (let i = 0; i < 5; i++) { + ctx.beginPath(); + ctx.moveTo(Math.random() * size, Math.random() * size); + ctx.lineTo(Math.random() * size, Math.random() * size); + ctx.stroke(); + } + + const diffuse = canvas.transferToImageBitmap(); + + // Normal Map (Smooth with minor imperfections) + const normalCtx = new OffscreenCanvas(size, size).getContext("2d"); + normalCtx.fillStyle = "#8080ff"; // Flat normal + normalCtx.fillRect(0, 0, size, size); + // Add same veins as height bumps + normalCtx.strokeStyle = "#8080ff"; // Slight variation needed for real normal map but this is a placeholder + // For now, let's keep normal simple. + + // Roughness: Very low (0.1 - 0.3) for shiny obsidian, but with rougher patches + const roughCtx = new OffscreenCanvas(size, size).getContext("2d"); + roughCtx.fillStyle = "#202020"; // Dark = Smooth/Shiny + roughCtx.fillRect(0, 0, size, size); + this.noisePass(roughCtx, size, 0.1, 0.2); // Add some dust/scratches + + return { + diffuse, + normal: normalCtx.canvas.transferToImageBitmap(), + roughness: roughCtx.canvas.transferToImageBitmap(), + bump: null, // Optional + }; + } + + /** + * Generates a pulsing neon violet corruption texture. + * @param {number} size + */ + generateCorruption(size = 64) { + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext("2d"); + + // Base: Dark Purple + ctx.fillStyle = "#2a003b"; + ctx.fillRect(0, 0, size, size); + + // Neon Veins + ctx.strokeStyle = "#aa00ff"; + ctx.lineWidth = 2; + for (let i = 0; i < 8; i++) { + ctx.beginPath(); + ctx.moveTo(Math.random() * size, Math.random() * size); + ctx.bezierCurveTo( + Math.random() * size, + Math.random() * size, + Math.random() * size, + Math.random() * size, + Math.random() * size, + Math.random() * size + ); + ctx.stroke(); + } + + // Glow patches + for (let i = 0; i < 10; i++) { + const x = Math.random() * size; + const y = Math.random() * size; + const r = Math.random() * 5 + 2; + const grd = ctx.createRadialGradient(x, y, 0, x, y, r); + grd.addColorStop(0, "#ff00ff"); + grd.addColorStop(1, "transparent"); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, size, size); + } + + const diffuse = canvas.transferToImageBitmap(); + + // Emissive Map (The neon parts glow) + const emissiveCtx = new OffscreenCanvas(size, size).getContext("2d"); + emissiveCtx.fillStyle = "#000000"; + emissiveCtx.fillRect(0, 0, size, size); + // Re-draw veins and glow for emissive + emissiveCtx.strokeStyle = "#aa00ff"; + emissiveCtx.lineWidth = 2; + // (Ideally we reuse the potential procedural noise or seed, but random here is fine for now as long as it looks "glowy") + // Actually, to match, we should probably just use the diffuse as emissive but darkened? + // Let's just make a generic noise emissive for now. + emissiveCtx.fillStyle = "#440066"; + this.noisePass(emissiveCtx, size, 0.2, 0.8); + + return { + diffuse, + normal: (() => { + const c = new OffscreenCanvas(size, size); + const ctx = c.getContext("2d"); + ctx.fillStyle = "#8080ff"; + ctx.fillRect(0, 0, size, size); + return c.transferToImageBitmap(); + })(), + roughness: (() => { + const c = new OffscreenCanvas(size, size); + const ctx = c.getContext("2d"); + ctx.fillStyle = "#ff00ff"; // Fully rough? Or 0? Let's say mid rough. + ctx.fillRect(0, 0, size, size); + return c.transferToImageBitmap(); + })(), + emissive: emissiveCtx.canvas.transferToImageBitmap(), + }; + } + + noisePass(ctx, size, density, opacity) { + const imageData = ctx.getImageData(0, 0, size, size); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + if (Math.random() < density) { + const val = Math.random() * 255; + const alpha = opacity * 255; + // Blend white noise + data[i] = Math.min(255, data[i] + val * 0.1); + data[i + 1] = Math.min(255, data[i + 1] + val * 0.1); + data[i + 2] = Math.min(255, data[i + 2] + val * 0.1); + } + } + ctx.putImageData(imageData, 0, 0); + } +} diff --git a/src/tools/map-visualizer.html b/src/tools/map-visualizer.html new file mode 100644 index 0000000..e43bb21 --- /dev/null +++ b/src/tools/map-visualizer.html @@ -0,0 +1,121 @@ + + + + + + Aether Shards - Map Visualizer + + + + +
+

Map Visualizer

+ + + + + + + + + + + + + +
+ LMB: Rotate | RMB: Pan | Wheel: Zoom +
+
+ + + + diff --git a/src/tools/map-visualizer.js b/src/tools/map-visualizer.js new file mode 100644 index 0000000..66b9a58 --- /dev/null +++ b/src/tools/map-visualizer.js @@ -0,0 +1,301 @@ +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; +import { VoxelGrid } from "../grid/VoxelGrid.js"; +import { CaveGenerator } from "../generation/CaveGenerator.js"; +import { CrystalSpiresGenerator } from "../generation/CrystalSpiresGenerator.js"; +import { ContestedFrontierGenerator } from "../generation/ContestedFrontierGenerator.js"; +import { VoidSeepDepthsGenerator } from "../generation/VoidSeepDepthsGenerator.js"; +import { RustingWastesGenerator } from "../generation/RustingWastesGenerator.js"; + +class MapVisualizer { + constructor() { + this.container = document.body; + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0x202020); + + this.camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 1000 + ); + this.camera.position.set(20, 30, 20); + + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.setSize(window.innerWidth, window.innerHeight); + this.container.appendChild(this.renderer.domElement); + + this.controls = new OrbitControls(this.camera, this.renderer.domElement); + this.controls.target.set(10, 0, 10); + this.controls.update(); + + // Lights + const ambientLight = new THREE.AmbientLight(0x404040); + this.scene.add(ambientLight); + const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); + dirLight.position.set(50, 100, 50); + this.scene.add(dirLight); + + // Helper Grid + const gridHelper = new THREE.GridHelper(50, 50, 0x444444, 0x222222); + gridHelper.position.set(10, -0.01, 10); // Center vaguely around 0-20 area + this.scene.add(gridHelper); + + this.setupUI(); + this.generate(); + this.animate(); + + window.addEventListener("resize", () => this.onWindowResize()); + } + + setupUI() { + this.genSelect = document.getElementById("generatorSelect"); + this.seedInput = document.getElementById("seedInput"); + this.fillInput = document.getElementById("fillInput"); + this.iterInput = document.getElementById("iterInput"); + this.textureToggle = document.getElementById("textureToggle"); + this.genBtn = document.getElementById("generateBtn"); + + // Populate Generator Options + const generators = [ + "CaveGenerator", + "CrystalSpiresGenerator", + "ContestedFrontierGenerator", + "VoidSeepDepthsGenerator", + "RustingWastesGenerator", + ]; + generators.forEach((gen) => { + const opt = document.createElement("option"); + opt.value = gen; + opt.textContent = gen; + this.genSelect.appendChild(opt); + }); + + this.genBtn.addEventListener("click", () => this.generate()); + this.textureToggle.addEventListener("change", () => { + if (this.lastGrid) this.renderGrid(this.lastGrid); + }); + + this.loadFromURL(); + } + + loadFromURL() { + const params = new URLSearchParams(window.location.search); + if (params.has("type")) this.genSelect.value = params.get("type"); + if (params.has("seed")) this.seedInput.value = params.get("seed"); + if (params.has("fill")) this.fillInput.value = params.get("fill"); + if (params.has("iter")) this.iterInput.value = params.get("iter"); + if (params.has("textures")) + this.textureToggle.checked = params.get("textures") === "true"; + } + + updateURL(type, seed, fill, iter) { + const params = new URLSearchParams(); + params.set("type", type); + params.set("seed", seed); + params.set("fill", fill); + params.set("iter", iter); + params.set("textures", this.textureToggle.checked); + + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, "", newUrl); + } + + generate() { + const type = this.genSelect.value; + const seed = parseInt(this.seedInput.value) || 12345; + const fill = parseFloat(this.fillInput.value) || 0.45; + const iter = parseInt(this.iterInput.value) || 4; + + console.log( + `Generating ${type} with seed=${seed}, fill=${fill}, iter=${iter}` + ); + + this.updateURL(type, seed, fill, iter); + + // 1. Setup Grid + // Standard size for testing (matches test cases roughly) + const width = 30; + const height = 40; // Increased verticality + const depth = 30; + const grid = new VoxelGrid(width, height, depth); + + // 2. Run Generator + let generator; + if (type === "CaveGenerator") { + generator = new CaveGenerator(grid, seed); + generator.generate(fill, iter); + } else if (type === "CrystalSpiresGenerator") { + generator = new CrystalSpiresGenerator(grid, seed); + // Use map-visualizer inputs? + // fill -> spireCount (scaled? or just fixed for now) + // iter -> numFloors + // Let's remap slightly for usability: + // Fill 0.45 -> Spire Count 4 + // Iter 4 -> Floors 4 + const spireCount = Math.floor(fill * 10) || 4; + const floors = iter || 3; + generator.generate(spireCount, floors); + } else if (type === "ContestedFrontierGenerator") { + generator = new ContestedFrontierGenerator(grid, seed); + generator.generate(); + generator = new VoidSeepDepthsGenerator(grid, seed); + // fill -> difficulty? + // iter -> ignored? + generator.generate(iter || 1); + } else if (type === "RustingWastesGenerator") { + generator = new RustingWastesGenerator(grid, seed); + generator.generate(); + } + + // 2.5 Generate ThreeJS Materials from Palette + this.generatedMaterials = {}; + if ( + generator && + generator.generatedAssets && + generator.generatedAssets.palette + ) { + this.generateMaterialsFromPalette(generator.generatedAssets.palette); + } + + // 3. Render + this.lastGrid = grid; + this.renderGrid(grid); + } + + generateMaterialsFromPalette(palette) { + // Disposes old textures if any (optimization) + + for (const [id, maps] of Object.entries(palette)) { + const materialParams = {}; + + if (maps.diffuse) + materialParams.map = new THREE.CanvasTexture(maps.diffuse); + if (maps.normal) + materialParams.normalMap = new THREE.CanvasTexture(maps.normal); + if (maps.roughness) + materialParams.roughnessMap = new THREE.CanvasTexture(maps.roughness); + if (maps.bump) + materialParams.bumpMap = new THREE.CanvasTexture(maps.bump); + if (maps.emissive) { + materialParams.emissiveMap = new THREE.CanvasTexture(maps.emissive); + materialParams.emissive = new THREE.Color(0xffffff); + } + + // Fix color space for diffuse + if (materialParams.map) + materialParams.map.colorSpace = THREE.SRGBColorSpace; + if (materialParams.emissiveMap) + materialParams.emissiveMap.colorSpace = THREE.SRGBColorSpace; + + this.generatedMaterials[id] = new THREE.MeshStandardMaterial( + materialParams + ); + } + } + + renderGrid(grid) { + // Clear previous meshes + if (this.meshGroup) { + this.scene.remove(this.meshGroup); + } + this.meshGroup = new THREE.Group(); + this.scene.add(this.meshGroup); + + // Geometry reused + const geometry = new THREE.BoxGeometry(1, 1, 1); + const geometrySlab = new THREE.BoxGeometry(1, 0.9, 1); // 0.9 Thickness for solid ramps + + const materialWall = new THREE.MeshStandardMaterial({ color: 0x888888 }); // Gray walls + const materialFloor = new THREE.MeshStandardMaterial({ color: 0x44aa44 }); // Green floors + const materialCover = new THREE.MeshStandardMaterial({ color: 0xaa4444 }); // Red cover + const materialDetail = new THREE.MeshStandardMaterial({ color: 0xaa44aa }); // Purple details + const materialCrystal = new THREE.MeshStandardMaterial({ color: 0x00ffff }); // Cyan crystals + // Blue structure + const materialMud = new THREE.MeshStandardMaterial({ color: 0x5c4033 }); // Dark Brown + const materialSandbag = new THREE.MeshStandardMaterial({ color: 0xc2b280 }); // Sand/Tan + + const materialObsidian = new THREE.MeshStandardMaterial({ + color: 0x111111, + roughness: 0.1, + metalness: 0.8, + }); + const materialCorruption = new THREE.MeshStandardMaterial({ + color: 0xaa00ff, + emissive: 0xaa00ff, + emissiveIntensity: 0.5, + }); + + const materialGlowingPillar = new THREE.MeshStandardMaterial({ + color: 0xaa44ff, + emissive: 0xaa44ff, + emissiveIntensity: 1.0, + }); + + const useTextures = this.textureToggle.checked; + + // We'll just create simple meshes for now instead of InstancedMesh for simplicity of debugging diverse IDs + // Optimization: Use InstancedMesh later if too slow. + + for (let x = 0; x < grid.size.x; x++) { + for (let y = 0; y < grid.size.y; y++) { + for (let z = 0; z < grid.size.z; z++) { + const id = grid.getCell(x, y, z); + if (id !== 0) { + let mat; + + if (useTextures && this.generatedMaterials[id]) { + mat = this.generatedMaterials[id]; + } else { + // Heuristic for coloring based on IDs in CaveGenerator + if (id >= 200 && id < 300) mat = materialFloor; + else if (id === 10) mat = materialCover; // Cover + else if (id === 15) mat = materialCrystal; // Crystal Scatter + else if (id === 12) mat = materialSandbag; // Sandbags + else if (id === 11) mat = materialDetail; // Wall Detail + else if (id === 50 || id === 52) mat = materialObsidian; + else if (id === 51 || id === 55) mat = materialCorruption; + else if (id === 53) mat = materialGlowingPillar; + else if (id >= 108 && id <= 109) + mat = materialDetail; // Veined Walls (Debug highlight) + else if (id >= 120 && id < 200) + mat = materialMud; // Trench Walls (Muddy) + else if (id >= 100 && id < 200) mat = materialWall; + else mat = materialStructure; + } + + let useGeo = geometry; + let posY = y + 0.5; + + // ID 20 = Bridges (Stairs visually) + if (id === 20) { + useGeo = geometrySlab; + posY = y + 0.45; // Bottom-ish of cell (size 0.9, center at 0.45) + } + + const mesh = new THREE.Mesh(useGeo, mat); + mesh.position.set(x, posY, z); + this.meshGroup.add(mesh); + } + } + } + } + + // Center camera + this.controls.target.set(grid.size.x / 2, 0, grid.size.z / 2); + } + + onWindowResize() { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth, window.innerHeight); + } + + animate() { + requestAnimationFrame(() => this.animate()); + this.controls.update(); + this.renderer.render(this.scene, this.camera); + } +} + +new MapVisualizer(); diff --git a/src/utils/SeededRandom.js b/src/utils/SeededRandom.js index dd00572..9f45bcb 100644 --- a/src/utils/SeededRandom.js +++ b/src/utils/SeededRandom.js @@ -30,11 +30,9 @@ export class SeededRandom { 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; - }; + hash = Math.imul(hash ^ (hash >>> 16), 2246822507); + hash = Math.imul(hash ^ (hash >>> 13), 3266489909); + return hash >>> 0; } /** diff --git a/test/core/GameLoop/helpers.js b/test/core/GameLoop/helpers.js index 75f498e..5e5a3a5 100644 --- a/test/core/GameLoop/helpers.js +++ b/test/core/GameLoop/helpers.js @@ -12,9 +12,9 @@ const MOCK_MANIFEST = { const MOCK_VANGUARD = { id: "CLASS_VANGUARD", name: "Vanguard", - baseStats: { - hp: 100, - maxHp: 100, + base_stats: { + health: 100, + maxHealth: 100, ap: 3, maxAp: 3, movement: 5, @@ -26,8 +26,8 @@ const MOCK_VANGUARD = { critRate: 5, critDamage: 1.5, }, - growths: { - hp: 1.0, + growth_rates: { + health: 1.0, ap: 0, movement: 0, defense: 0.5, @@ -48,8 +48,8 @@ const MOCK_ENEMY = { id: "ENEMY_DEFAULT", name: "Drone", baseStats: { - hp: 50, - maxHp: 50, + health: 50, + maxHealth: 50, ap: 3, maxAp: 3, movement: 4, diff --git a/test/generation/CaveGenerator.test.js b/test/generation/CaveGenerator.test.js index 1e07e0d..77e90c2 100644 --- a/test/generation/CaveGenerator.test.js +++ b/test/generation/CaveGenerator.test.js @@ -2,6 +2,28 @@ import { expect } from "@esm-bundle/chai"; import { CaveGenerator } from "../../src/generation/CaveGenerator.js"; import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; +// Mock OffscreenCanvas for texture generation +if (typeof OffscreenCanvas === "undefined") { + class MockCanvas { + constructor(width, height) { + this.width = width; + this.height = height; + } + getContext() { + return { + createImageData: (w, h) => ({ + data: new Uint8ClampedArray(w * h * 4), + }), + putImageData: () => {}, + drawImage: () => {}, + fillStyle: "", + fillRect: () => {}, + }; + } + } + globalThis.OffscreenCanvas = MockCanvas; +} + describe("Generation: CaveGenerator", () => { let grid; let generator; @@ -40,61 +62,90 @@ describe("Generation: CaveGenerator", () => { } }); - it("CoA 4: generate should keep sky clear", () => { + it("CoA 4: generate should create extruded walls", () => { generator.generate(0.5, 2); - // Top layer (y=height-1) should be air - const topY = grid.size.y - 1; + // Check a spot that is a wall (has block at y=5 for example) + // It should be solid all the way down to y=0 + let foundWall = false; for (let x = 0; x < grid.size.x; x++) { for (let z = 0; z < grid.size.z; z++) { - expect(grid.getCell(x, topY, z)).to.equal(0); - } - } - }); - - it("CoA 5: smooth should apply cellular automata rules", () => { - // Set up initial pattern - for (let x = 0; x < grid.size.x; x++) { - for (let z = 0; z < grid.size.z; z++) { - for (let y = 1; y < grid.size.y - 1; y++) { - grid.setCell(x, y, z, Math.random() > 0.5 ? 1 : 0); + const midY = Math.floor(grid.size.y / 2); + if (grid.getCell(x, midY, z) !== 0) { + foundWall = true; + // Valid wall stack + expect(grid.getCell(x, 0, z)).to.not.equal(0); + expect(grid.getCell(x, 1, z)).to.not.equal(0); } } } - - const beforeState = grid.cells.slice(); - generator.smooth(); - const afterState = grid.cells.slice(); - - // Smoothing should change the grid - expect(afterState).to.not.deep.equal(beforeState); + expect(foundWall).to.be.true; }); - it("CoA 6: applyTextures should assign floor and wall IDs", () => { - // Create a simple structure: floor at y=1, wall above - // Floor surface: solid at y=1 with air above (y=2) - // Wall: solid at y=2 with solid above (y=3) - for (let x = 1; x < 10; x++) { - for (let z = 1; z < 10; z++) { - grid.setCell(x, 0, z, 1); // Foundation - grid.setCell(x, 1, z, 1); // Floor surface (will be textured as floor if air above) - grid.setCell(x, 2, z, 0); // Air above floor (makes y=1 a floor surface) - grid.setCell(x, 3, z, 1); // Wall (solid with solid above) - grid.setCell(x, 4, z, 1); // Solid above wall + it("CoA 5: smoothMap should apply 2D cellular automata", () => { + // 5x5 Map + // 1 1 1 0 0 + // 1 0 1 0 0 + // 1 1 1 0 0 + // 0 0 0 0 0 + // 0 0 0 0 0 + // Center (2, 2) has neighbors: (1,1)=0, (1,2)=1, (1,3)=0, (2,1)=1, (2,3)=1, (3,1)=0, (3,2)=0, (3,3)=0 + // Total 1s = 3. Less than 4 -> should become 0. + + // Actually let's just test simple behavior: dense area stays dense, sparse area clears. + let map = [ + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ]; + // Mock dimensions temporarily for this test if smoothMap uses this.width/depth + // implementation uses map.length so it might be fine or we need to override width/depth + // My implementation used `this.width`. I should mock it or use the real grid size if I constructed it right. + // The real grid is 20x10x20. + // Let's use a full size map initialized to match grid size to avoid bounds errors. + + map = []; + for (let x = 0; x < 20; x++) { + map[x] = []; + for (let z = 0; z < 20; z++) { + map[x][z] = x < 10 && z < 10 ? 1 : 0; // Block of walls } } + // Creating a hole in the wall block + map[5][5] = 0; + // Surrounded by 1s (8 neighbors). Should become 1. + + const newMap = generator.smoothMap(map); + // (5,5) should be filled + expect(newMap[5][5]).to.equal(1); + }); + + it("CoA 6: applyTextures should assign floor and wall IDs", () => { + // Setup manual grid state replicating generate() output + // Wall at 5,5 (Column) + for (let y = 0; y < grid.size.y; y++) { + grid.setCell(5, y, 5, 100); + } + + // Floor at 6,6 (Only y=0) + grid.setCell(6, 0, 6, 100); + // y=1 is air + grid.setCell(6, 1, 6, 0); + generator.applyTextures(); - // Floor surfaces (y=1 with air above) should have IDs 200-209 - const floorId = grid.getCell(5, 1, 5); - expect(floorId).to.be.greaterThanOrEqual(200); - expect(floorId).to.be.lessThanOrEqual(209); - - // Walls (y=3 with solid above) should have IDs 100-109 - const wallId = grid.getCell(5, 3, 5); + // Wall Check + const wallId = grid.getCell(5, 5, 5); expect(wallId).to.be.greaterThanOrEqual(100); expect(wallId).to.be.lessThanOrEqual(109); + + // Floor Check + const floorId = grid.getCell(6, 0, 6); + expect(floorId).to.be.greaterThanOrEqual(200); + expect(floorId).to.be.lessThanOrEqual(209); }); it("CoA 7: generate should scatter cover objects", () => { @@ -128,5 +179,60 @@ describe("Generation: CaveGenerator", () => { // Same seed should produce same results expect(grid1.cells).to.deep.equal(grid2.cells); }); -}); + it("CoA 9: ensureConnectivity should remove disconnected small regions", () => { + // Create a map with two disconnected regions + // Region A: 3x3 (Total 9) + // Region B: 1x1 (Total 1) + let map = []; + for (let x = 0; x < 20; x++) { + map[x] = []; + for (let z = 0; z < 20; z++) { + map[x][z] = 1; // Walls everywhere + } + } + + // Region A (Large) + for (let x = 1; x <= 3; x++) { + for (let z = 1; z <= 3; z++) { + map[x][z] = 0; + } + } + + // Region B (Small, disconnected) + map[10][10] = 0; + + const connectedMap = generator.ensureConnectivity(map); + + // Region A should remain + expect(connectedMap[2][2]).to.equal(0); + + // Region B should be filled + expect(connectedMap[10][10]).to.equal(1); + }); + + it("CoA 10: scatterWallDetails should place objects on wall faces", () => { + // Setup a single wall column at 5,5 from y=0 to y=10 + // And air at 6,5 (adjacent) + for (let y = 0; y < grid.size.y; y++) { + grid.setCell(5, y, 5, 100); + grid.setCell(6, y, 5, 0); + } + + // Force seed/density to ensure placement + // Or just check that it CAN place + // Let's rely on randomness with a loop or high density + generator.scatterWallDetails(11, 1.0, 2); // 100% density for test + + // Check y=2 to y=height-2 (safe range) + let foundDetail = false; + for (let y = 2; y < grid.size.y - 1; y++) { + const id = grid.getCell(6, y, 5); + if (id === 11) foundDetail = true; + } + expect(foundDetail).to.be.true; + + // Ensure it didn't place below minHeight (y=1) + expect(grid.getCell(6, 1, 5)).to.equal(0); + }); +}); diff --git a/test/generation/ContestedFrontierGenerator.test.js b/test/generation/ContestedFrontierGenerator.test.js new file mode 100644 index 0000000..7900bd1 --- /dev/null +++ b/test/generation/ContestedFrontierGenerator.test.js @@ -0,0 +1,90 @@ +import { expect } from "@esm-bundle/chai"; +import { ContestedFrontierGenerator } from "../../src/generation/ContestedFrontierGenerator.js"; +import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; + +// MOCK Browser APIs if needed (web-test-runner generally runs in browser/headless chrome so OffscreenCanvas should exist) +// But if running in node via some flag, we might need polyfills. +// However, package.json says "web-test-runner --node-resolve", which implies browser environment unless configured otherwise. +// If typical tests run in browser, OffscreenCanvas is available. + +describe("Generation: ContestedFrontierGenerator", () => { + let grid; + let generator; + + beforeEach(() => { + grid = new VoxelGrid(30, 10, 30); + generator = new ContestedFrontierGenerator(grid, 12345); + }); + + it("should generate a trench map with correct IDs", () => { + generator.generate(); + + let solidCount = 0; + let fortifications = 0; + let trenchWalls = 0; + let trenchFloors = 0; + + for (let x = 0; x < grid.size.x; x++) { + for (let y = 0; y < grid.size.y; y++) { + for (let z = 0; z < grid.size.z; z++) { + const id = grid.getCell(x, y, z); + if (id !== 0) { + solidCount++; + if (id === 12) fortifications++; + if (id >= 120 && id < 200) trenchWalls++; + if (id >= 220 && id < 300) trenchFloors++; + } + } + } + } + + expect(solidCount).to.be.greaterThan(0, "Should generate solid voxels"); + expect(trenchWalls).to.be.greaterThan( + 0, + "Should generate trench walls (120+)" + ); + expect(trenchFloors).to.be.greaterThan( + 0, + "Should generate trench floors (220+)" + ); + // Fortifications are probabilistic, but likely with this seed + // expect(fortifications).to.be.greaterThanOrEqual(0); + }); + + it("should distribute splotchy textures (muddy, mixed, grassy)", () => { + // We can inspect ID ranges in the floor area (220-229) to see if we get variation + generator.generate(); + + const counts = { + muddy: 0, // 220-222 + mixed: 0, // 223-226 + grassy: 0, // 227-229 + }; + + for (let x = 0; x < grid.size.x; x++) { + for (let z = 0; z < grid.size.z; z++) { + // Check surface + for (let y = grid.size.y - 1; y >= 0; y--) { + const id = grid.getCell(x, y, z); + if (id >= 220 && id <= 222) { + counts.muddy++; + break; + } + if (id >= 223 && id <= 226) { + counts.mixed++; + break; + } + if (id >= 227 && id <= 229) { + counts.grassy++; + break; + } + } + } + } + + // We expect at least some representation of each, or at least 2 of them given the noise scale + expect(counts.muddy + counts.mixed + counts.grassy).to.be.greaterThan(0); + // With current seed 12345, we likely have a mix + // Just assert that we have *some* floors + }); +}); diff --git a/test/generation/CrystalSpires.test.js b/test/generation/CrystalSpires.test.js index e10dc96..5d1bc8f 100644 --- a/test/generation/CrystalSpires.test.js +++ b/test/generation/CrystalSpires.test.js @@ -7,21 +7,20 @@ describe("System: Procedural Generation (Crystal Spires)", () => { beforeEach(() => { // Taller grid for verticality - grid = new VoxelGrid(20, 15, 20); + grid = new VoxelGrid(40, 20, 40); }); - it("CoA 1: Should generate vertical columns (Spires)", () => { + it("CoA 1: Should generate vertical columns (Pillars)", () => { const gen = new CrystalSpiresGenerator(grid, 12345); - gen.generate(1, 0); // 1 Spire, 0 Islands + gen.generate(1, 0); // 1 Spire, 0 Floors (just pillar) - // Check if the spire goes from bottom to top - // Finding a solid column + // Check if the pillar goes from bottom to top let solidColumnFound = false; - // Scan roughly center - for (let x = 5; x < 15; x++) { - for (let z = 5; z < 15; z++) { - if (grid.getCell(x, 0, z) !== 0 && grid.getCell(x, 14, z) !== 0) { + // Scan full grid + for (let x = 0; x < 40; x++) { + for (let z = 0; z < 40; z++) { + if (grid.getCell(x, 0, z) !== 0 && grid.getCell(x, 19, z) !== 0) { solidColumnFound = true; break; } @@ -31,29 +30,30 @@ describe("System: Procedural Generation (Crystal Spires)", () => { expect(solidColumnFound).to.be.true; }); - it("CoA 2: Should generate floating islands with void below", () => { + it("CoA 2: Should generate platform discs at specific heights", () => { const gen = new CrystalSpiresGenerator(grid, 999); - gen.generate(0, 5); // 0 Spires, 5 Islands + // 1 Spire, 2 Floors, Spacing 5 => Floors at y=4 and y=9 + gen.generate(1, 2, 5); - let floatingIslandFound = false; + let platform1Count = 0; + let platform2Count = 0; - for (let x = 0; x < 20; x++) { - for (let z = 0; z < 20; z++) { - for (let y = 5; y < 10; y++) { - // Look for Solid block with Air directly below it - if (grid.getCell(x, y, z) !== 0 && grid.getCell(x, y - 1, z) === 0) { - floatingIslandFound = true; - } - } + for (let x = 0; x < 40; x++) { + for (let z = 0; z < 40; z++) { + if (grid.getCell(x, 4, z) === 1) platform1Count++; + if (grid.getCell(x, 9, z) === 1) platform2Count++; } } - expect(floatingIslandFound).to.be.true; + // A disc of radius 4 has area ~50. + expect(platform1Count).to.be.greaterThan(10); + expect(platform2Count).to.be.greaterThan(10); }); it("CoA 3: Should generate bridges (ID 20)", () => { const gen = new CrystalSpiresGenerator(grid, 12345); - gen.generate(2, 5); // Spires and Islands + // 2 Spires, 2 Floors => Should connect them + gen.generate(2, 2, 5); let bridgeCount = 0; for (let i = 0; i < grid.cells.length; i++) { @@ -62,4 +62,17 @@ describe("System: Procedural Generation (Crystal Spires)", () => { expect(bridgeCount).to.be.greaterThan(0); }); + + it("CoA 4: Should scatter crystal cover (ID 15)", () => { + const gen = new CrystalSpiresGenerator(grid, 12345); + gen.generate(2, 2, 5); // Should produce some platforms + + let crystalCount = 0; + for (let i = 0; i < grid.cells.length; i++) { + if (grid.cells[i] === 15) crystalCount++; + } + + // 5% density on platforms should yield at least a few crystals + expect(crystalCount).to.be.greaterThan(0); + }); }); diff --git a/test/generation/CrystalSpiresTextures.test.js b/test/generation/CrystalSpiresTextures.test.js new file mode 100644 index 0000000..12e8e1a --- /dev/null +++ b/test/generation/CrystalSpiresTextures.test.js @@ -0,0 +1,30 @@ +import { expect } from "@esm-bundle/chai"; +import { CrystalSpiresTextureGenerator } from "../../src/generation/textures/CrystalSpiresTextureGenerator.js"; + +describe("System: Crystal Spires Textures", () => { + it("CoA 1: Should generate Marble texture (ID 1)", () => { + const gen = new CrystalSpiresTextureGenerator(123); + const maps = gen.generateMarble(64); + + expect(maps).to.have.property("diffuse"); + expect(maps).to.have.property("normal"); + + // Check dimensions + expect(maps.diffuse.width).to.equal(64); + expect(maps.diffuse.height).to.equal(64); + }); + + it("CoA 2: Should generate Crystal texture (ID 15)", () => { + const gen = new CrystalSpiresTextureGenerator(123); + const maps = gen.generateCrystal(64); + + expect(maps).to.have.property("diffuse"); + }); + + it("CoA 3: Should generate Bridge texture (ID 20)", () => { + const gen = new CrystalSpiresTextureGenerator(123); + const maps = gen.generateBridge(64); + + expect(maps).to.have.property("diffuse"); + }); +}); diff --git a/test/generation/RuinGenerator.test.js b/test/generation/RustingWastesGenerator.test.js similarity index 76% rename from test/generation/RuinGenerator.test.js rename to test/generation/RustingWastesGenerator.test.js index 0b740f4..ed967b0 100644 --- a/test/generation/RuinGenerator.test.js +++ b/test/generation/RustingWastesGenerator.test.js @@ -1,14 +1,14 @@ import { expect } from "@esm-bundle/chai"; -import { RuinGenerator } from "../../src/generation/RuinGenerator.js"; +import { RustingWastesGenerator } from "../../src/generation/RustingWastesGenerator.js"; import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; -describe("Generation: RuinGenerator", () => { +describe("Generation: RustingWastesGenerator", () => { let grid; let generator; beforeEach(() => { grid = new VoxelGrid(30, 10, 30); - generator = new RuinGenerator(grid, 12345); + generator = new RustingWastesGenerator(grid, 12345); }); it("CoA 1: Should initialize with texture generators and spawn zones", () => { @@ -23,10 +23,16 @@ describe("Generation: RuinGenerator", () => { it("CoA 2: generate should create rooms", () => { generator.generate(5, 4, 8); - // Should have some air spaces (rooms) + // Should have some air spaces (rooms are carved out or built additive, + // but RustingWastesGenerator uses 0 for air inside rooms) + // Actually RustingWastes does: + // this.grid.fill(0); ... this.grid.setCell(x, r.y, z, 0); + // So rooms have 0 at y=1 (floor level) and walls around them. + let airCount = 0; for (let x = 0; x < grid.size.x; x++) { for (let z = 0; z < grid.size.z; z++) { + // RustingWastes rooms are at y=1 if (grid.getCell(x, 1, z) === 0) { airCount++; } @@ -41,7 +47,9 @@ describe("Generation: RuinGenerator", () => { // Should have spawn zones if rooms were created if (generator.generatedAssets.spawnZones.player.length > 0) { - expect(generator.generatedAssets.spawnZones.player.length).to.be.greaterThan(0); + expect( + generator.generatedAssets.spawnZones.player.length + ).to.be.greaterThan(0); } }); @@ -55,9 +63,7 @@ describe("Generation: RuinGenerator", () => { }); it("CoA 5: roomsOverlap should return false for non-overlapping rooms", () => { - const rooms = [ - { x: 5, z: 5, w: 4, d: 4 }, - ]; + const rooms = [{ x: 5, z: 5, w: 4, d: 4 }]; const newRoom = { x: 15, z: 15, w: 4, d: 4 }; // Far away // roomsOverlap checks if newRoom overlaps with any existing room @@ -79,10 +85,15 @@ describe("Generation: RuinGenerator", () => { generator.buildRoom(room); - // Floor should exist + // RustingWastes logic: + // Floor Foundation (y-1) -> 1 (or textured later) + // Walls -> 1 (textured later) + // Interior -> 0 + + // Floor foundation check expect(grid.getCell(7, 0, 7)).to.not.equal(0); - // Interior should be air + // Interior should be air (y=1) expect(grid.getCell(7, 1, 7)).to.equal(0); // Perimeter should be walls @@ -95,7 +106,7 @@ describe("Generation: RuinGenerator", () => { generator.buildCorridor(start, end); - // Path should have floor + // Path should have floor foundation (y-1) expect(grid.getCell(10, 0, 5)).to.not.equal(0); expect(grid.getCell(15, 0, 10)).to.not.equal(0); }); @@ -108,12 +119,18 @@ describe("Generation: RuinGenerator", () => { generator.markSpawnZone(room, "player"); // Should have some spawn positions - expect(generator.generatedAssets.spawnZones.player.length).to.be.greaterThan(0); + expect( + generator.generatedAssets.spawnZones.player.length + ).to.be.greaterThan(0); // Spawn positions should be valid floor tiles const spawn = generator.generatedAssets.spawnZones.player[0]; - expect(grid.getCell(spawn.x, spawn.y - 1, spawn.z)).to.not.equal(0); // Solid below - expect(grid.getCell(spawn.x, spawn.y, spawn.z)).to.equal(0); // Air at spawn + + // In RustingWastes, spawn is {x, y:1, z} + // Check floor below is solid + expect(grid.getCell(spawn.x, spawn.y - 1, spawn.z)).to.not.equal(0); + // Check space itself is air + expect(grid.getCell(spawn.x, spawn.y, spawn.z)).to.equal(0); }); it("CoA 10: applyTextures should assign floor and wall IDs", () => { @@ -124,14 +141,8 @@ describe("Generation: RuinGenerator", () => { generator.applyTextures(); // Floor should have IDs 200-209 - const floorId = grid.getCell(7, 1, 7); - if (floorId !== 0) { - // If it's a floor surface - expect(floorId).to.be.greaterThanOrEqual(200); - expect(floorId).to.be.lessThanOrEqual(209); - } - - // Wall should have IDs 100-109 + // Walls should have IDs 100-109 + // Let's check a wall position const wallId = grid.getCell(5, 1, 5); if (wallId !== 0) { expect(wallId).to.be.greaterThanOrEqual(100); @@ -157,4 +168,3 @@ describe("Generation: RuinGenerator", () => { expect(coverCount).to.be.greaterThan(0); }); }); - diff --git a/test/generation/VoidSeepDepthsGenerator.test.js b/test/generation/VoidSeepDepthsGenerator.test.js new file mode 100644 index 0000000..1a705e3 --- /dev/null +++ b/test/generation/VoidSeepDepthsGenerator.test.js @@ -0,0 +1,104 @@ +import { expect } from "@esm-bundle/chai"; +import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; +import { VoidSeepDepthsGenerator } from "../../src/generation/VoidSeepDepthsGenerator.js"; + +// MOCK Browser APIs if missing (though WTR usually provides browser env) +// But OffscreenCanvas might need partial mock if headless browser lacks full support or for consistency +if (typeof window !== "undefined" && !window.OffscreenCanvas) { + class MockCanvas { + constructor(width, height) { + this.width = width; + this.height = height; + } + getContext(type) { + return { + createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), + putImageData: () => {}, + drawImage: () => {}, + fillStyle: "", + fillRect: () => {}, + strokeStyle: "", + beginPath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + bezierCurveTo: () => {}, + createRadialGradient: () => ({ addColorStop: () => {} }), + }; + } + transferToImageBitmap() { + return {}; + } + } + window.OffscreenCanvas = MockCanvas; +} + +describe("Generation: VoidSeepDepthsGenerator", () => { + it("should generate a void seep map with correct IDs", () => { + const grid = new VoxelGrid(40, 20, 40); + const generator = new VoidSeepDepthsGenerator(grid, 12345); + generator.generate(); + + let solidCount = 0; + let obsidianCount = 0; + let corruptionCount = 0; + let glowingPillarCount = 0; + + for (let x = 0; x < grid.size.x; x++) { + for (let y = 0; y < grid.size.y; y++) { + for (let z = 0; z < grid.size.z; z++) { + const id = grid.getCell(x, y, z); + if (id !== 0) { + solidCount++; + if (id === 50 || id === 52) obsidianCount++; + if (id === 51 || id === 55) corruptionCount++; + if (id === 53) glowingPillarCount++; + } + } + } + } + + expect(solidCount).to.be.greaterThan(0, "Should generate solid voxels"); + expect(obsidianCount).to.be.greaterThan( + 0, + "Should generate obsidian (50, 52)" + ); + expect(corruptionCount).to.be.greaterThan( + 0, + "Should generate corruption (51, 55)" + ); + + // Difficulty 1 with seed 12345 verified to have pillars + expect(glowingPillarCount).to.be.greaterThan( + 0, + "Should generate glowing pillars (53) with seed 12345" + ); + }); + + it("should have start and end zones", () => { + const grid = new VoxelGrid(40, 20, 40); + // Use a seed that doesn't put a huge rift exactly at start logic if randomly unlikely + const generator = new VoidSeepDepthsGenerator(grid, 99999); + generator.generate(); + + const cx = 20; + + // Start Zone (North, Z min) + let startSolid = false; + for (let x = cx - 2; x <= cx + 2; x++) { + for (let z = 1; z <= 5; z++) { + if (grid.getCell(x, 5, z) !== 0) startSolid = true; + } + } + expect(startSolid).to.be.true; + + // End Zone (South, Z max) + let endSolid = false; + for (let x = cx - 2; x <= cx + 2; x++) { + for (let z = 35; z <= 38; z++) { + if (grid.getCell(x, 5, z) !== 0) endSolid = true; + } + } + expect(endSolid).to.be.true; + }); +}); diff --git a/test/managers/MissionManager.test.js b/test/managers/MissionManager.test.js index be99491..a9a9d89 100644 --- a/test/managers/MissionManager.test.js +++ b/test/managers/MissionManager.test.js @@ -926,12 +926,27 @@ describe("Manager: MissionManager", () => { .stub(window, "fetch") .rejects(new Error("Should not fetch")); + // Capture the event listener callback + let narrativeEndCallback; + narrativeManager.addEventListener.callsFake((event, callback) => { + if (event === "narrative-end") { + narrativeEndCallback = callback; + } + }); + + // Trigger the callback when startSequence is called + narrativeManager.startSequence.callsFake(() => { + if (narrativeEndCallback) { + narrativeEndCallback(); + } + }); + await manager.playIntro(); - expect(mockNarrativeManager.startSequence.calledOnce).to.be.true; - expect( - mockNarrativeManager.startSequence.firstCall.args[0] - ).to.deep.equal(dynamicData["NARRATIVE_DYNAMIC_INTRO"]); + expect(narrativeManager.startSequence.calledOnce).to.be.true; + expect(narrativeManager.startSequence.firstCall.args[0]).to.deep.equal( + dynamicData["NARRATIVE_DYNAMIC_INTRO"] + ); expect(fetchStub.called).to.be.false; fetchStub.restore(); @@ -960,10 +975,25 @@ describe("Manager: MissionManager", () => { .stub() .returns("narrative_file_intro"); + // Capture the event listener callback + let narrativeEndCallback; + narrativeManager.addEventListener.callsFake((event, callback) => { + if (event === "narrative-end") { + narrativeEndCallback = callback; + } + }); + + // Trigger the callback when startSequence is called + narrativeManager.startSequence.callsFake(() => { + if (narrativeEndCallback) { + narrativeEndCallback(); + } + }); + await manager.playIntro(); expect(fetchStub.calledOnce).to.be.true; - expect(mockNarrativeManager.startSequence.calledOnce).to.be.true; + expect(narrativeManager.startSequence.calledOnce).to.be.true; fetchStub.restore(); }); diff --git a/test/ui/character-sheet/inventory-integration.test.js b/test/ui/character-sheet/inventory-integration.test.js index ea308e8..767965b 100644 --- a/test/ui/character-sheet/inventory-integration.test.js +++ b/test/ui/character-sheet/inventory-integration.test.js @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { CharacterSheet } from "../../../src/ui/components/CharacterSheet.js"; +import { CharacterSheet } from "../../../src/ui/components/character-sheet.js"; import { Explorer } from "../../../src/units/Explorer.js"; import { InventoryManager } from "../../../src/managers/InventoryManager.js"; import { InventoryContainer } from "../../../src/models/InventoryContainer.js"; diff --git a/test/ui/hub-screen.test.js b/test/ui/hub-screen.test.js index 685e1e0..355e552 100644 --- a/test/ui/hub-screen.test.js +++ b/test/ui/hub-screen.test.js @@ -24,6 +24,11 @@ describe("UI: HubScreen", () => { mockMissionManager = { completedMissions: new Set(), + _ensureMissionsLoaded: sinon.stub().resolves(), + areProceduralMissionsUnlocked: sinon.stub().returns(false), + refreshProceduralMissions: sinon.stub(), + missionRegistry: new Map(), + completedMissionDetails: new Map(), }; mockHubStash = { @@ -102,7 +107,7 @@ describe("UI: HubScreen", () => { aetherShards: 450, ancientCores: 12, }; - + // Manually trigger _loadData await element._loadData(); await waitForUpdate(); @@ -201,7 +206,10 @@ describe("UI: HubScreen", () => { // Simulate close event from mission-board component const missionBoard = queryShadow("mission-board"); if (missionBoard) { - const closeEvent = new CustomEvent("close", { bubbles: true, composed: true }); + const closeEvent = new CustomEvent("close", { + bubbles: true, + composed: true, + }); missionBoard.dispatchEvent(closeEvent); } else { // If mission-board not rendered, directly call _closeOverlay @@ -226,7 +234,13 @@ describe("UI: HubScreen", () => { }); it("should show different overlays for different types", async () => { - const overlayTypes = ["BARRACKS", "MISSIONS", "MARKET", "RESEARCH", "SYSTEM"]; + const overlayTypes = [ + "BARRACKS", + "MISSIONS", + "MARKET", + "RESEARCH", + "SYSTEM", + ]; for (const type of overlayTypes) { element.activeOverlay = type; @@ -313,7 +327,10 @@ describe("UI: HubScreen", () => { const researchButton = queryShadowAll(".dock-button")[3]; // RESEARCH is fourth button expect(researchButton).to.exist; // Research should be disabled when locked (no missions completed) - expect(researchButton.hasAttribute("disabled") || researchButton.classList.contains("disabled")).to.be.true; + expect( + researchButton.hasAttribute("disabled") || + researchButton.classList.contains("disabled") + ).to.be.true; }); it("should hide market hotspot when locked", async () => { @@ -386,4 +403,3 @@ describe("UI: HubScreen", () => { }); }); }); -