fix: Improve generation logic and visualization
This commit is contained in:
parent
25c9d47587
commit
5c5a030bbb
6 changed files with 1068 additions and 93 deletions
57
specs/BridgeCreation.spec.md
Normal file
57
specs/BridgeCreation.spec.md
Normal 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
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue