fix: Improve generation logic and visualization

This commit is contained in:
Matthew Mone 2026-01-14 11:11:22 -08:00
parent 25c9d47587
commit 5c5a030bbb
6 changed files with 1068 additions and 93 deletions

View file

@ -0,0 +1,57 @@
# **Crystal Spires: Light Bridge Specification**
This document details the generation logic for "Light Bridges" (ID 20) connecting floating islands in the Crystal Spires biome.
## **1. Visual & Gameplay Goals**
- **Visuals:** Translucent, glowing blue pathways projected between solid structures.
- **Gameplay:** Must be strictly walkable.
- **Max Step Height:** 1 Voxel (Units cannot jump 2 blocks up).
- **Width:** 1 voxel (Standard) or 2 voxels (Main thoroughfares).
- **Material:** "Hard Light" (ID 20). Unlike stone, it has no "thickness" below it (floating).
- **Trajectory:** Organic curves or arcs, not strict straight lines. Can defy gravity slightly (arcing up/down).
## **2. Generation Logic: Additive Bezier Arcs**
Bridges are constructed using **Quadratic Bezier Curves** that strictly adhere to an **Additive-Only** rule.
**Algorithm:**
1. **Anchor Selection (Edge-to-Edge):**
- Do not connect centers. Find the voxel on the _perimeter_ of Island A that is closest to Island B.
- Find the corresponding perimeter voxel on Island B (or the surface of a Spire).
2. **Control Point Calculation:**
- Calculate the midpoint between Start and End.
- Apply a randomized **Vertical Offset** (Arch) and **Lateral Offset** (Curve) to the midpoint to create the Bezier Control Point ($P_1$).
3. **Voxelization (Non-Destructive):**
- Interpolate the curve.
- **Collision Scan:** Before placing a voxel, verify that the target coordinate is Air (ID 0) AND the two voxels above it (Headroom) are Air (ID 0).
- **Placement:** If and ONLY if the scan is clear, set the target coordinate to Bridge (ID 20).
- **Abort:** If the scan finds any solid block in the path (Floor or Headroom), the bridge segment stops or the entire connection is recalculated.
## **3. Conditions of Acceptance (CoA)**
**CoA 1: Walkability (Gradient)**
- Between any two adjacent voxels on the bridge path, the Y-difference must be <= 1.
**CoA 2: Strictly Additive (No Destruction)**
- The generator must **never** set a Voxel ID to 0 (Air) if it was previously non-zero.
- The generator must **never** set a Voxel ID to 20 (Bridge) if it was previously non-zero.
- Bridges must weave around existing geometry or fail; they cannot tunnel.
**CoA 3: Headroom Validation**
- Instead of _carving_ headroom, the generator must _validate_ headroom.
- A bridge voxel is only valid if Grid[y+1] and Grid[y+2] are already 0.
**CoA 4: Connectivity**
- The bridge must connect the edge of the start platform to the edge of the end platform without leaving a gap.
**CoA 5: Global Reachability (No Orphans)**
- After all bridges are generated, a **Flood Fill** check from the player spawn zone is mandatory.
- Any platform that is not reached by this fill is considered an "Orphan."
- **Resolution:** Orphans must be explicitly connected via a fallback teleport node. The Orphan will have one teleport node randomly placed on it, and the other will be randomly on a non-Orphan platform

View file

@ -69,23 +69,36 @@ export class CaveGenerator extends BaseGenerator {
} }
generate(fillPercent = 0.45, iterations = 4) { generate(fillPercent = 0.45, iterations = 4) {
// 0. Decide Layout Topology
// "Tunnel" = Open Z-min and Z-max
// "Dead End" = Open Z-min only
this.isTunnel = this.rng.next() > 0.5;
// 1. Initialize 2D Map // 1. Initialize 2D Map
// 1 = Wall, 0 = Floor // 1 = Wall, 0 = Floor
let map = []; let map = [];
for (let x = 0; x < this.width; x++) { for (let x = 0; x < this.width; x++) {
map[x] = []; map[x] = [];
for (let z = 0; z < this.depth; z++) { for (let z = 0; z < this.depth; z++) {
// Border is always wall // Border Logic
if ( const isSideBorder = x === 0 || x === this.width - 1;
x === 0 || const isBackBorder = z === this.depth - 1;
x === this.width - 1 || const isFrontBorder = z === 0;
z === 0 ||
z === this.depth - 1 let isSolid = false;
) {
map[x][z] = 1; if (isSideBorder) {
isSolid = true; // Sides are always walls (but low)
} else if (isFrontBorder) {
isSolid = false; // Entrance always open
} else if (isBackBorder) {
isSolid = !this.isTunnel; // Back open if tunnel, closed if dead end
} else { } else {
map[x][z] = this.rng.chance(fillPercent) ? 1 : 0; // Internal
isSolid = this.rng.chance(fillPercent);
} }
map[x][z] = isSolid ? 1 : 0;
} }
} }
@ -95,22 +108,23 @@ export class CaveGenerator extends BaseGenerator {
} }
// 3. Post-Process: Ensure Connectivity // 3. Post-Process: Ensure Connectivity
// Note: ensureConnectivity respects 0s, so open entrances remain open if connected
map = this.ensureConnectivity(map); map = this.ensureConnectivity(map);
// 4. Extrude to 3D Voxel Grid // 4. Extrude to 3D Voxel Grid
for (let x = 0; x < this.width; x++) { for (let x = 0; x < this.width; x++) {
for (let z = 0; z < this.depth; z++) { for (let z = 0; z < this.depth; z++) {
const isWall = map[x][z] === 1; if (map[x][z] === 1) {
if (isWall) {
// Wall Logic // Wall Logic
// Cap all walls to height 6 for visibility (cutoff view)
const MAX_WALL_HEIGHT = 6;
// Vary height slightly for "voxel aesthetic" // Vary height slightly for "voxel aesthetic"
// height-1 or height // height-1 or height, but capped at MAX_WALL_HEIGHT
const wallHeight = this.height - (this.rng.next() > 0.5 ? 1 : 0); const baseHeight = Math.min(this.height, MAX_WALL_HEIGHT);
const wallHeight = baseHeight - (this.rng.next() > 0.5 ? 1 : 0);
for (let y = 0; y < wallHeight; y++) { 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); this.grid.setCell(x, y, z, 100);
} }
} else { } else {
@ -285,16 +299,23 @@ export class CaveGenerator extends BaseGenerator {
for (let x = 0; x < this.width; x++) { for (let x = 0; x < this.width; x++) {
newMap[x] = []; newMap[x] = [];
for (let z = 0; z < this.depth; z++) { for (let z = 0; z < this.depth; z++) {
// Borders stay walls // Border Logic (Must match generate loop to avoid closing entrances)
if ( const isSideBorder = x === 0 || x === this.width - 1;
x === 0 || const isFrontBorder = z === 0;
x === this.width - 1 || const isBackBorder = z === this.depth - 1;
z === 0 ||
z === this.depth - 1 if (isSideBorder) {
) {
newMap[x][z] = 1; newMap[x][z] = 1;
continue; continue;
} }
if (isFrontBorder) {
newMap[x][z] = 0;
continue;
}
if (isBackBorder) {
newMap[x][z] = this.isTunnel ? 0 : 1;
continue;
}
const neighbors = this.get2DNeighbors(map, x, z); const neighbors = this.get2DNeighbors(map, x, z);
if (neighbors > 4) newMap[x][z] = 1; if (neighbors > 4) newMap[x][z] = 1;

File diff suppressed because it is too large Load diff

View file

@ -37,6 +37,16 @@ export class VoxelManager {
color: 0x00ffff, color: 0x00ffff,
emissive: 0x004444, emissive: 0x004444,
}), // Crystal }), // Crystal
20: new THREE.MeshStandardMaterial({
color: 0x0088ff,
emissive: 0x002244,
transparent: true,
opacity: 0.8,
}), // Hard Light Bridge
22: new THREE.MeshStandardMaterial({
color: 0xff00ff,
emissive: 0x440044,
}), // Teleporter Node
}; };
// Shared Geometry // Shared Geometry

View file

@ -109,6 +109,11 @@
Apply Textures Apply Textures
</label> </label>
<label style="cursor: pointer">
<input type="checkbox" id="zoneToggle" />
Show Spawn Zones
</label>
<button id="generateBtn">Generate Map</button> <button id="generateBtn">Generate Map</button>
<div style="margin-top: 10px; font-size: 0.8em; color: #888"> <div style="margin-top: 10px; font-size: 0.8em; color: #888">

View file

@ -54,6 +54,7 @@ class MapVisualizer {
this.fillInput = document.getElementById("fillInput"); this.fillInput = document.getElementById("fillInput");
this.iterInput = document.getElementById("iterInput"); this.iterInput = document.getElementById("iterInput");
this.textureToggle = document.getElementById("textureToggle"); this.textureToggle = document.getElementById("textureToggle");
this.zoneToggle = document.getElementById("zoneToggle");
this.genBtn = document.getElementById("generateBtn"); this.genBtn = document.getElementById("generateBtn");
// Populate Generator Options // Populate Generator Options
@ -75,6 +76,9 @@ class MapVisualizer {
this.textureToggle.addEventListener("change", () => { this.textureToggle.addEventListener("change", () => {
if (this.lastGrid) this.renderGrid(this.lastGrid); if (this.lastGrid) this.renderGrid(this.lastGrid);
}); });
this.zoneToggle.addEventListener("change", () => {
if (this.zoneGroup) this.zoneGroup.visible = this.zoneToggle.checked;
});
this.loadFromURL(); this.loadFromURL();
} }
@ -87,6 +91,8 @@ class MapVisualizer {
if (params.has("iter")) this.iterInput.value = params.get("iter"); if (params.has("iter")) this.iterInput.value = params.get("iter");
if (params.has("textures")) if (params.has("textures"))
this.textureToggle.checked = params.get("textures") === "true"; this.textureToggle.checked = params.get("textures") === "true";
if (params.has("zones"))
this.zoneToggle.checked = params.get("zones") === "true";
} }
updateURL(type, seed, fill, iter) { updateURL(type, seed, fill, iter) {
@ -96,6 +102,7 @@ class MapVisualizer {
params.set("fill", fill); params.set("fill", fill);
params.set("iter", iter); params.set("iter", iter);
params.set("textures", this.textureToggle.checked); params.set("textures", this.textureToggle.checked);
params.set("zones", this.zoneToggle.checked);
const newUrl = `${window.location.pathname}?${params.toString()}`; const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, "", newUrl); window.history.replaceState({}, "", newUrl);
@ -114,10 +121,10 @@ class MapVisualizer {
this.updateURL(type, seed, fill, iter); this.updateURL(type, seed, fill, iter);
// 1. Setup Grid // 1. Setup Grid
// Standard size for testing (matches test cases roughly) // Standard size (matches GameLoop default)
const width = 30; const width = 20;
const height = 40; // Increased verticality const height = 20;
const depth = 30; const depth = 20;
const grid = new VoxelGrid(width, height, depth); const grid = new VoxelGrid(width, height, depth);
// 2. Run Generator // 2. Run Generator
@ -139,9 +146,8 @@ class MapVisualizer {
} else if (type === "ContestedFrontierGenerator") { } else if (type === "ContestedFrontierGenerator") {
generator = new ContestedFrontierGenerator(grid, seed); generator = new ContestedFrontierGenerator(grid, seed);
generator.generate(); generator.generate();
} else if (type === "VoidSeepDepthsGenerator") {
generator = new VoidSeepDepthsGenerator(grid, seed); generator = new VoidSeepDepthsGenerator(grid, seed);
// fill -> difficulty?
// iter -> ignored?
generator.generate(iter || 1); generator.generate(iter || 1);
} else if (type === "RustingWastesGenerator") { } else if (type === "RustingWastesGenerator") {
generator = new RustingWastesGenerator(grid, seed); generator = new RustingWastesGenerator(grid, seed);
@ -161,6 +167,20 @@ class MapVisualizer {
// 3. Render // 3. Render
this.lastGrid = grid; this.lastGrid = grid;
this.renderGrid(grid); this.renderGrid(grid);
// 4. Render Spawn Zones (if available)
if (
generator &&
generator.generatedAssets &&
generator.generatedAssets.spawnZones
) {
this.renderZones(generator.generatedAssets.spawnZones);
} else {
if (this.zoneGroup) {
this.scene.remove(this.zoneGroup);
this.zoneGroup = null;
}
}
} }
generateMaterialsFromPalette(palette) { generateMaterialsFromPalette(palette) {
@ -211,7 +231,20 @@ class MapVisualizer {
const materialCover = new THREE.MeshStandardMaterial({ color: 0xaa4444 }); // Red cover const materialCover = new THREE.MeshStandardMaterial({ color: 0xaa4444 }); // Red cover
const materialDetail = new THREE.MeshStandardMaterial({ color: 0xaa44aa }); // Purple details const materialDetail = new THREE.MeshStandardMaterial({ color: 0xaa44aa }); // Purple details
const materialCrystal = new THREE.MeshStandardMaterial({ color: 0x00ffff }); // Cyan crystals const materialCrystal = new THREE.MeshStandardMaterial({ color: 0x00ffff }); // Cyan crystals
// Blue structure const materialStructure = new THREE.MeshStandardMaterial({
color: 0x4444aa,
}); // Blue structure
const materialBridge = new THREE.MeshStandardMaterial({
color: 0x00ffff,
transparent: true,
opacity: 0.6,
});
const materialTeleporter = new THREE.MeshStandardMaterial({
color: 0xff00ff,
emissive: 0xff00ff,
emissiveIntensity: 1.0,
});
const materialMud = new THREE.MeshStandardMaterial({ color: 0x5c4033 }); // Dark Brown const materialMud = new THREE.MeshStandardMaterial({ color: 0x5c4033 }); // Dark Brown
const materialSandbag = new THREE.MeshStandardMaterial({ color: 0xc2b280 }); // Sand/Tan const materialSandbag = new THREE.MeshStandardMaterial({ color: 0xc2b280 }); // Sand/Tan
@ -248,7 +281,9 @@ class MapVisualizer {
mat = this.generatedMaterials[id]; mat = this.generatedMaterials[id];
} else { } else {
// Heuristic for coloring based on IDs in CaveGenerator // Heuristic for coloring based on IDs in CaveGenerator
if (id >= 200 && id < 300) mat = materialFloor; if (id === 20) mat = materialBridge;
else if (id === 22) mat = materialTeleporter;
else if (id >= 200 && id < 300) mat = materialFloor;
else if (id === 10) mat = materialCover; // Cover else if (id === 10) mat = materialCover; // Cover
else if (id === 15) mat = materialCrystal; // Crystal Scatter else if (id === 15) mat = materialCrystal; // Crystal Scatter
else if (id === 12) mat = materialSandbag; // Sandbags else if (id === 12) mat = materialSandbag; // Sandbags
@ -285,6 +320,47 @@ class MapVisualizer {
this.controls.target.set(grid.size.x / 2, 0, grid.size.z / 2); this.controls.target.set(grid.size.x / 2, 0, grid.size.z / 2);
} }
renderZones(spawnZones) {
if (this.zoneGroup) {
this.scene.remove(this.zoneGroup);
}
this.zoneGroup = new THREE.Group();
this.scene.add(this.zoneGroup);
// Player Zones (Blue)
const playerGeo = new THREE.BoxGeometry(0.8, 1, 0.8);
const playerMat = new THREE.MeshBasicMaterial({
color: 0x0000ff,
transparent: true,
opacity: 0.3,
});
// Enemy Zones (Red)
const enemyMat = new THREE.MeshBasicMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.3,
});
if (spawnZones.player) {
spawnZones.player.forEach((pos) => {
const mesh = new THREE.Mesh(playerGeo, playerMat);
mesh.position.set(pos.x, pos.y + 0.5, pos.z);
this.zoneGroup.add(mesh);
});
}
if (spawnZones.enemy) {
spawnZones.enemy.forEach((pos) => {
const mesh = new THREE.Mesh(playerGeo, enemyMat);
mesh.position.set(pos.x, pos.y + 0.5, pos.z);
this.zoneGroup.add(mesh);
});
}
this.zoneGroup.visible = this.zoneToggle.checked;
}
onWindowResize() { onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix(); this.camera.updateProjectionMatrix();