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) {
|
||||
// 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 = Wall, 0 = Floor
|
||||
let map = [];
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
map[x] = [];
|
||||
for (let z = 0; z < this.depth; z++) {
|
||||
// Border is always wall
|
||||
if (
|
||||
x === 0 ||
|
||||
x === this.width - 1 ||
|
||||
z === 0 ||
|
||||
z === this.depth - 1
|
||||
) {
|
||||
map[x][z] = 1;
|
||||
// Border Logic
|
||||
const isSideBorder = x === 0 || x === this.width - 1;
|
||||
const isBackBorder = z === this.depth - 1;
|
||||
const isFrontBorder = z === 0;
|
||||
|
||||
let isSolid = false;
|
||||
|
||||
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 {
|
||||
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
|
||||
// Note: ensureConnectivity respects 0s, so open entrances remain open if connected
|
||||
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) {
|
||||
if (map[x][z] === 1) {
|
||||
// Wall Logic
|
||||
// Cap all walls to height 6 for visibility (cutoff view)
|
||||
const MAX_WALL_HEIGHT = 6;
|
||||
|
||||
// Vary height slightly for "voxel aesthetic"
|
||||
// height-1 or height
|
||||
const wallHeight = this.height - (this.rng.next() > 0.5 ? 1 : 0);
|
||||
// height-1 or height, but capped at MAX_WALL_HEIGHT
|
||||
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++) {
|
||||
// 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 {
|
||||
|
|
@ -285,16 +299,23 @@ export class CaveGenerator extends BaseGenerator {
|
|||
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
|
||||
) {
|
||||
// Border Logic (Must match generate loop to avoid closing entrances)
|
||||
const isSideBorder = x === 0 || x === this.width - 1;
|
||||
const isFrontBorder = z === 0;
|
||||
const isBackBorder = z === this.depth - 1;
|
||||
|
||||
if (isSideBorder) {
|
||||
newMap[x][z] = 1;
|
||||
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);
|
||||
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,
|
||||
emissive: 0x004444,
|
||||
}), // 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
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@
|
|||
Apply Textures
|
||||
</label>
|
||||
|
||||
<label style="cursor: pointer">
|
||||
<input type="checkbox" id="zoneToggle" />
|
||||
Show Spawn Zones
|
||||
</label>
|
||||
|
||||
<button id="generateBtn">Generate Map</button>
|
||||
|
||||
<div style="margin-top: 10px; font-size: 0.8em; color: #888">
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class MapVisualizer {
|
|||
this.fillInput = document.getElementById("fillInput");
|
||||
this.iterInput = document.getElementById("iterInput");
|
||||
this.textureToggle = document.getElementById("textureToggle");
|
||||
this.zoneToggle = document.getElementById("zoneToggle");
|
||||
this.genBtn = document.getElementById("generateBtn");
|
||||
|
||||
// Populate Generator Options
|
||||
|
|
@ -75,6 +76,9 @@ class MapVisualizer {
|
|||
this.textureToggle.addEventListener("change", () => {
|
||||
if (this.lastGrid) this.renderGrid(this.lastGrid);
|
||||
});
|
||||
this.zoneToggle.addEventListener("change", () => {
|
||||
if (this.zoneGroup) this.zoneGroup.visible = this.zoneToggle.checked;
|
||||
});
|
||||
|
||||
this.loadFromURL();
|
||||
}
|
||||
|
|
@ -87,6 +91,8 @@ class MapVisualizer {
|
|||
if (params.has("iter")) this.iterInput.value = params.get("iter");
|
||||
if (params.has("textures"))
|
||||
this.textureToggle.checked = params.get("textures") === "true";
|
||||
if (params.has("zones"))
|
||||
this.zoneToggle.checked = params.get("zones") === "true";
|
||||
}
|
||||
|
||||
updateURL(type, seed, fill, iter) {
|
||||
|
|
@ -96,6 +102,7 @@ class MapVisualizer {
|
|||
params.set("fill", fill);
|
||||
params.set("iter", iter);
|
||||
params.set("textures", this.textureToggle.checked);
|
||||
params.set("zones", this.zoneToggle.checked);
|
||||
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
|
|
@ -114,10 +121,10 @@ class MapVisualizer {
|
|||
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;
|
||||
// Standard size (matches GameLoop default)
|
||||
const width = 20;
|
||||
const height = 20;
|
||||
const depth = 20;
|
||||
const grid = new VoxelGrid(width, height, depth);
|
||||
|
||||
// 2. Run Generator
|
||||
|
|
@ -139,9 +146,8 @@ class MapVisualizer {
|
|||
} else if (type === "ContestedFrontierGenerator") {
|
||||
generator = new ContestedFrontierGenerator(grid, seed);
|
||||
generator.generate();
|
||||
} else if (type === "VoidSeepDepthsGenerator") {
|
||||
generator = new VoidSeepDepthsGenerator(grid, seed);
|
||||
// fill -> difficulty?
|
||||
// iter -> ignored?
|
||||
generator.generate(iter || 1);
|
||||
} else if (type === "RustingWastesGenerator") {
|
||||
generator = new RustingWastesGenerator(grid, seed);
|
||||
|
|
@ -161,6 +167,20 @@ class MapVisualizer {
|
|||
// 3. Render
|
||||
this.lastGrid = 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) {
|
||||
|
|
@ -211,7 +231,20 @@ class MapVisualizer {
|
|||
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 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 materialSandbag = new THREE.MeshStandardMaterial({ color: 0xc2b280 }); // Sand/Tan
|
||||
|
||||
|
|
@ -248,7 +281,9 @@ class MapVisualizer {
|
|||
mat = this.generatedMaterials[id];
|
||||
} else {
|
||||
// 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 === 15) mat = materialCrystal; // Crystal Scatter
|
||||
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);
|
||||
}
|
||||
|
||||
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() {
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
|
|
|||
Loading…
Reference in a new issue