diff --git a/specs/BridgeCreation.spec.md b/specs/BridgeCreation.spec.md new file mode 100644 index 0000000..8e7e5ec --- /dev/null +++ b/specs/BridgeCreation.spec.md @@ -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 diff --git a/src/generation/CaveGenerator.js b/src/generation/CaveGenerator.js index c752aa2..5381231 100644 --- a/src/generation/CaveGenerator.js +++ b/src/generation/CaveGenerator.js @@ -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; diff --git a/src/generation/CrystalSpiresGenerator.js b/src/generation/CrystalSpiresGenerator.js index 319655d..98d3afc 100644 --- a/src/generation/CrystalSpiresGenerator.js +++ b/src/generation/CrystalSpiresGenerator.js @@ -36,6 +36,14 @@ export class CrystalSpiresGenerator extends BaseGenerator { generate(spireCount = 4, numFloors = 3, floorSpacing = 6) { this.grid.fill(0); // Start with Void (Sky) + // Reset generation data, passing empty arrays/objects but keeping palette if it exists + this.generatedAssets.spawnZones = { + player: [], + enemy: [], + }; + this.generatedAssets.spires = []; + this.generatedAssets.bridges = []; + // Track spires for connections // Each spire: { x, z, radius, platforms: [{y, r}] } const spires = []; @@ -48,13 +56,16 @@ export class CrystalSpiresGenerator extends BaseGenerator { const radius = this.rng.rangeInt(2, 3); // Thinner pillars // Try to find a spot not too close to others + // Dynamic spacing: On small maps (20x20), 10 is too big. Use min(10, width/3). + const minSpacing = Math.min(10, this.width / 3); + 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; + if (this.dist2D(x, z, s.x, s.z) < minSpacing) valid = false; } attempts++; } while (!valid && attempts < 10); @@ -65,24 +76,59 @@ export class CrystalSpiresGenerator extends BaseGenerator { spires.push({ x, z, radius, platforms: [] }); } } + this.generatedAssets.spires = spires; + this.generatedAssets.spires = spires; // 2. Generate Platforms (Sparse Levels) // Create global levels to ensure vertical progression + // 2. Generate Platforms (Sparse Levels) + // Create global levels to ensure vertical progression + + // Adaptive Height Logic: + // With height=10, we have range [1...7] roughly (headroom 3). + // If numFloors=3, we need to squeeze them. + // Ensure we fit at least `numFloors` if physically possible. + const maxUsableY = this.height - 3; + const minStartY = 1; + const usableHeight = maxUsableY - minStartY; + + // Recalculate spacing if default doesn't fit + // e.g. 3 floors. (3-1) gaps. Spacing = usable / (floors-1)? No, floors are at y0, y0+s, y0+2s + // Last floor y = start + (numFloors-1)*spacing. + // If start=2. max=7. range=5. 2 gaps. spacing=2.5. + + let actualSpacing = floorSpacing; + let actualStartY = 4; + + // If simple check fails + if (actualStartY + (numFloors - 1) * actualSpacing > maxUsableY) { + // Try to compress + actualStartY = 2; // Lower start + if (numFloors > 1) { + actualSpacing = Math.floor( + (maxUsableY - actualStartY) / (numFloors - 1) + ); + if (actualSpacing < 3) actualSpacing = 3; // Minimum viable spacing + } + } + for (let f = 0; f < numFloors; f++) { - const y = 4 + f * floorSpacing; - if (y >= this.height - 3) break; + const y = actualStartY + f * actualSpacing; + if (y > maxUsableY) break; // Pick 1-2 random spires for this level + // Pick random spires for this level const levelSpires = []; - const count = 1; // Strict 1 platform per level as requested - // Force at least 1, max 2. + // Ensure at least 2 platforms per level (if possible), or half the spires. + // This guarantees vertical options and prevents single-chain linearity. + const count = Math.max(2, Math.floor(spireCount / 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); + const discRadius = this.rng.rangeInt(3, 5); this.buildDisc(s.x, y, s.z, discRadius); s.platforms.push({ x: s.x, @@ -94,9 +140,8 @@ export class CrystalSpiresGenerator extends BaseGenerator { } } - // 3. Connect Spires (Bridges) - // Connect platforms at similar heights - this.connectSpires(spires); + // 3. Connect Spires (Moved to end to carve through crystals) + // this.connectSpires(spires); // 4. Define Spawn Zones // Player starts at the BOTTOM, Enemy at the TOP @@ -122,6 +167,23 @@ export class CrystalSpiresGenerator extends BaseGenerator { // ID 15 = Crystal formations // 3% Density (Clusters are larger so lower density) this.scatterCrystalClusters(15, 0.03); + + // 6. Connect Spires (Bridges) - Runs last to ensure pathways are clear + this.connectSpires(spires); + + // 7. Cleanup Reserved Headroom (ID 99 -> Air 0) + // We used ID 99... (Actually we removed ID 99 logic? No, buildBridge uses it? + // Wait, new Bezier logic DOES NOT set ID 99. It validates instead. + // So this cleanup loop is harmless but unnecessary. + for (let i = 0; i < this.grid.cells.length; i++) { + if (this.grid.cells[i] === 99) { + this.grid.cells[i] = 0; + } + } + + // 8. Ensure Global Connectivity + // Identify orphans and link via Teleporters + this.ensureGlobalConnectivity(spires); } buildPillar(centerX, centerZ, radius, height) { @@ -153,9 +215,79 @@ export class CrystalSpiresGenerator extends BaseGenerator { } } + /** + * Connects platforms within the SAME spire (Vertical/Spiral connectivity). + * Ensures that a single spire is walkable from bottom to top. + */ + connectVerticalLevels(spires) { + // console.log("Running connectVerticalLevels..."); + spires.forEach((s, sIdx) => { + // Sort by Height (Y) + const sortedPlats = [...s.platforms].sort((a, b) => a.y - b.y); + // console.log(`Spire ${sIdx} has ${sortedPlats.length} platforms.`); + + for (let i = 0; i < sortedPlats.length - 1; i++) { + const pA = sortedPlats[i]; + const pB = sortedPlats[i + 1]; + + const dy = pB.y - pA.y; + // console.log(`Spire ${sIdx} Pair ${i}->${i+1}: dy=${dy}`); + + // Limit vertical reach for single bridge + if (dy > 18) { + // console.log(" Skipped: Too far"); + continue; + } + + // We need to pick points on the rim that are offset to create a spiral. + // If we pick same angle, it's a vertical ladder (steep). + // Ideally 90-180 degrees offset. + + // Use random angle offset for variety? Or fixed for stability? + // Let's use 120 degrees. + const angleA = this.rng.next() * Math.PI * 2; + const angleB = angleA + Math.PI * 0.66; // 120 deg + + const rA = pA.radius - 0.5; // Inset slightly + const rB = pB.radius - 0.5; + + const start = { + x: pA.x + Math.cos(angleA) * rA, + y: pA.y, + z: pA.z + Math.sin(angleA) * rA, + }; + + const end = { + x: pB.x + Math.cos(angleB) * rB, + y: pB.y, + z: pB.z + Math.sin(angleB) * rB, + }; + + const success = this.buildBridge(start, end, { x: s.x, z: s.z }, 0.0); + // console.log(` BuildBridge Result: ${success}`); + + if (success) { + pA.connections.push(start); + pB.connections.push(end); + this.generatedAssets.bridges.push({ + fromSpire: sIdx, + toSpire: sIdx, // Same Spire + fromPlatIdx: s.platforms.indexOf(pA), + toPlatIdx: s.platforms.indexOf(pB), + start, + end, + }); + } + } + }); + } connectSpires(spires) { + // 0. Vertical Pass (Self-Connect) + this.connectVerticalLevels(spires); + const connectedPairs = new Set(); + // For each spire, try to connect to *multiple* nearest neighbors // For each spire, try to connect to *multiple* nearest neighbors for (let i = 0; i < spires.length; i++) { const sA = spires[i]; @@ -176,7 +308,8 @@ export class CrystalSpiresGenerator extends BaseGenerator { for (let n = 0; n < neighbors.length; n++) { // Stop if we have connected to enough neighbor SPIRES (not bridges) - if (connectedNeighbors >= 2) break; + // Increased from 2 to 4 to ensure we find the "Vertical Next" neighbor even if it's further away. + if (connectedNeighbors >= 4) break; const nObj = neighbors[n]; const sB = nObj.spire; @@ -212,11 +345,13 @@ export class CrystalSpiresGenerator extends BaseGenerator { // 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 - ) { + + const reqYdMax = Math.max(rimDist, 3) * 3.0; + const validGap = rimDist > 1.0 || yd > 3.0; + const validYdAbs = yd <= 12; + const validSlope = yd <= reqYdMax; + + if (validGap && validYdAbs && validSlope) { // Calculate Edge Points const dx_direct = pB.x - pA.x; const dz_direct = pB.z - pA.z; @@ -237,6 +372,11 @@ export class CrystalSpiresGenerator extends BaseGenerator { // Direct Mode (Spaced): Use 1.5 (Internal) for solid floor connection. const penetration = isTouching ? 0.0 : 1.5; + // Calculate vertical slope constraint + // Standard stairs are 1:1. We can allow maybe 1.5:1 for jumping/steep ramps. + const validSlope = yd <= Math.max(rimDist, 2) * 1.5; + if (!validSlope) continue; + if (isTouching) { // Spiral Mode: Use Tangent Vector (-nz, nx) // Try "Right" Tangent first @@ -244,8 +384,20 @@ export class CrystalSpiresGenerator extends BaseGenerator { 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! + startPt = this.getUncrowdedRimPoint( + pA, + tx, + tz, + penetration, + sA.radius + ); + endPt = this.getUncrowdedRimPoint( + pB, + tx, + tz, + penetration, + sB.radius + ); // Same vector for B! // If blocked, try "Left" Tangent if (!startPt || !endPt) { @@ -253,30 +405,83 @@ export class CrystalSpiresGenerator extends BaseGenerator { pA, -tx, -tz, - penetration + penetration, + sA.radius ); endPt = this.getUncrowdedRimPoint( pB, -tx, -tz, - penetration + penetration, + sB.radius ); } } 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 + startPt = this.getUncrowdedRimPoint( + pA, + nx, + nz, + penetration, + sA.radius + ); + endPt = this.getUncrowdedRimPoint( + pB, + -nx, + -nz, + penetration, + sB.radius + ); // Invert for B } if (startPt && endPt) { - pA.connections.push(startPt); - pB.connections.push(endPt); + // COLLISION CHECK: Ensure bridge doesn't clip ANY spire pillars + // We check against ALL spires (not just A and B, though they are most likely) + let blocked = false; + for (const s of spires) { + // Ignore the source and target pillars themselves (bridges obviously touch them) + if (s === sA || s === sB) continue; - this.buildBridge( - { x: startPt.x, y: pA.y, z: startPt.z }, - { x: endPt.x, y: pB.y, z: endPt.z } - ); - bridgesToThisNeighbor++; + // Check against pillar Radius (usually 2-3). Give 1.5 margin. + // Spire radius is stored in s.radius + if ( + this.checkCylinderCollision( + startPt.x, + startPt.z, + endPt.x, + endPt.z, + s.x, + s.z, + s.radius + 1.0 + ) + ) { + blocked = true; + break; + } + } + + if (!blocked) { + 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 }, + { x: sA.x, z: sA.z } + ); + bridgesToThisNeighbor++; + + this.generatedAssets.bridges.push({ + fromSpire: i, + toSpire: nObj.index, + fromPlatIdx: sA.platforms.indexOf(pA), + toPlatIdx: sB.platforms.indexOf(pB), + start: { x: startPt.x, y: pA.y, z: startPt.z }, + end: { x: endPt.x, y: pB.y, z: endPt.z }, + yd, + dist: rimDist, + }); + } } else if (!backupBridge && isTouching) { // Forced Spiral Backup const tx = -nz; @@ -289,6 +494,8 @@ export class CrystalSpiresGenerator extends BaseGenerator { backupBridge = { start: { x: idealStartX, y: pA.y, z: idealStartZ }, end: { x: idealEndX, y: pB.y, z: idealEndZ }, + targetSpireIndex: nObj.index, + targetPlatIdx: nObj.platIdx, pA, bestP: pB, startPt: { x: idealStartX, z: idealStartZ }, @@ -304,6 +511,8 @@ export class CrystalSpiresGenerator extends BaseGenerator { backupBridge = { start: { x: idealStartX, y: pA.y, z: idealStartZ }, end: { x: idealEndX, y: pB.y, z: idealEndZ }, + targetSpireIndex: nObj.index, + targetPlatIdx: sB.platforms.indexOf(pB), pA, bestP: pB, startPt: { x: idealStartX, z: idealStartZ }, @@ -315,6 +524,7 @@ export class CrystalSpiresGenerator extends BaseGenerator { } } + // If we built ANY bridges to this neighbor, count it // If we built ANY bridges to this neighbor, count it if (bridgesToThisNeighbor > 0) { connectedNeighbors++; @@ -325,16 +535,270 @@ export class CrystalSpiresGenerator extends BaseGenerator { if (connectedNeighbors < 2 && backupBridge) { backupBridge.pA.connections.push(backupBridge.startPt); backupBridge.bestP.connections.push(backupBridge.endPt); - this.buildBridge(backupBridge.start, backupBridge.end); + this.buildBridge(backupBridge.start, backupBridge.end, { + x: spires[i].x, + z: spires[i].z, + }); + + // Track backup bridge too! + this.generatedAssets.bridges.push({ + fromSpire: i, + toSpire: backupBridge.targetSpireIndex, + fromPlatIdx: sA.platforms.indexOf(pA), + toPlatIdx: backupBridge.targetPlatIdx, // Need to ensure backupBridge has this! + start: backupBridge.start, + end: backupBridge.end, + yd: Math.abs(backupBridge.end.y - backupBridge.start.y), + dist: this.dist2D( + backupBridge.start.x, + backupBridge.start.z, + backupBridge.end.x, + backupBridge.end.z + ), + }); + } + } + + // ORPHAN RESCUE PASS + // Ensure every platform has at least 1 connection + for (let i = 0; i < spires.length; i++) { + const s = spires[i]; + for (const p of s.platforms) { + if (p.connections.length === 0) { + // Determine if this is truly isolated or just top/bottom + // Actually, ALL platforms safely reachable should be connected. + + // Find ANY valid neighbor platform + let bestTarget = null; + let minMetric = Infinity; + + for (let j = 0; j < spires.length; j++) { + if (i === j) continue; + const neighbor = spires[j]; + for (const np of neighbor.platforms) { + const dist = this.dist2D(p.x, p.z, np.x, np.z); + const dy = Math.abs(p.y - np.y); + + // Relaxed constraints for rescue + if (dist < 20 && dy < 15) { + let metric = dist + dy * 2; // Penalize height + // STEEPNESS PENALTY: If slope > 1 (dy > dist), it's very hard for A* to build without spiraling. + // Penalize heavily to prefer cross-spire bridges (dist > dy). + if (dist < dy) metric += 1000; + + if (metric < minMetric) { + minMetric = metric; + bestTarget = { sp: neighbor, pl: np, sIdx: j }; + } + } + } + } + + if (bestTarget) { + // Force build + // Use direct vector + const dx = bestTarget.pl.x - p.x; + const dz = bestTarget.pl.z - p.z; + const dLen = Math.sqrt(dx * dx + dz * dz); + + // Failsafe for stacked platforms (dLen ~ 0) + const nx = dLen > 0.001 ? dx / dLen : 1; + const nz = dLen > 0.001 ? dz / dLen : 0; + + // console.log("Rescue:", {pX: p.x, pY: p.y, pZ: p.z, tX: bestTarget.pl.x, tY: bestTarget.pl.y, tZ: bestTarget.pl.z, sR: s.radius, tR: bestTarget.sp.radius}); + + // Use simpler rim points (0.5 penetration) + const startPt = this.getUncrowdedRimPoint(p, nx, nz, 0.5, s.radius); + const endPt = this.getUncrowdedRimPoint( + bestTarget.pl, + -nx, + -nz, + 0.5, + bestTarget.sp.radius + ); + + if (startPt && endPt) { + // Construct full 3D points + const start3D = { x: startPt.x, y: p.y, z: startPt.z }; + const end3D = { x: endPt.x, y: bestTarget.pl.y, z: endPt.z }; + + if (this.buildBridge(start3D, end3D, { x: s.x, z: s.z }, 0.0)) { + p.connections.push(startPt); + bestTarget.pl.connections.push(endPt); + + this.generatedAssets.bridges.push({ + fromSpire: i, + toSpire: bestTarget.sIdx, + fromPlatIdx: s.platforms.indexOf(p), // Find index + toPlatIdx: spires[bestTarget.sIdx].platforms.indexOf( + bestTarget.pl + ), + start: start3D, + end: end3D, + yd: Math.abs(end3D.y - start3D.y), + dist: dLen - p.radius - bestTarget.pl.radius, + }); + } + + // console.log(`Rescued Orphan Spire ${i} Plat Y=${p.y} -> Spire ${bestTarget.sIdx} Y=${bestTarget.pl.y}`); + } + } + } + } + + // COMPONENT LINKING PASS (Global Connectivity) + // 1. Identify Connected Components + let allPlats = []; + spires.forEach((s, sIdx) => { + s.platforms.forEach((p, pIdx) => { + p.componentId = -1; + p.globalId = `${sIdx}:${pIdx}`; + p.debugSpireIdx = sIdx; + allPlats.push(p); + }); + }); + + // Graph via Union-Find using Bridges List + const unionSet = new Map(); + const findRoot = (id) => { + if (!unionSet.has(id)) unionSet.set(id, id); + if (unionSet.get(id) === id) return id; + const root = findRoot(unionSet.get(id)); + unionSet.set(id, root); + return root; + }; + const union = (idA, idB) => { + const rootA = findRoot(idA); + const rootB = findRoot(idB); + if (rootA !== rootB) unionSet.set(rootA, rootB); + }; + + // Process existing bridges + this.generatedAssets.bridges.forEach((b) => { + // ROBUST: Use indices directly + if (b.fromPlatIdx !== undefined && b.toPlatIdx !== undefined) { + const idA = `${b.fromSpire}:${b.fromPlatIdx}`; + const idB = `${b.toSpire}:${b.toPlatIdx}`; + union(idA, idB); + } else { + // Fallback for any legacy bridges (shouldn't happen with new code) + let pA = null, + pB = null; + const sA = spires[b.fromSpire]; + const sB = spires[b.toSpire]; + for (let i = 0; i < sA.platforms.length; i++) { + if (Math.abs(sA.platforms[i].y - b.start.y) < 1.0) { + pA = `${b.fromSpire}:${i}`; + break; + } + } + for (let i = 0; i < sB.platforms.length; i++) { + if (Math.abs(sB.platforms[i].y - b.end.y) < 1.0) { + pB = `${b.toSpire}:${i}`; + break; + } + } + if (pA && pB) union(pA, pB); + } + }); + + // Ensure all singular nodes are in the set + allPlats.forEach((p) => findRoot(p.globalId)); + + // Group by Root + const components = new Map(); + allPlats.forEach((p) => { + const root = findRoot(p.globalId); + if (!components.has(root)) components.set(root, []); + components.get(root).push(p); + }); + + if (components.size > 1) { + // Sort components by average Y height + const sortedComps = Array.from(components.values()).sort((cA, cB) => { + const avgYA = cA.reduce((sum, p) => sum + p.y, 0) / cA.length; + const avgYB = cB.reduce((sum, p) => sum + p.y, 0) / cB.length; + return avgYA - avgYB; + }); + + // Chain them! 0->1, 1->2... + for (let i = 0; i < sortedComps.length - 1; i++) { + const cA = sortedComps[i]; + const cB = sortedComps[i + 1]; + + // Find closest pair between cA and cB + let bestPair = null; + let minD = Infinity; + + for (const pA of cA) { + for (const pB of cB) { + const dist = this.dist2D(pA.x, pA.z, pB.x, pB.z); + const dy = Math.abs(pA.y - pB.y); + let metric = dist + dy * 2; + // STEEPNESS PENALTY: Prevent impossible same-spire vertical connections + if (dist < dy) metric += 1000; + + if (metric < minD) { + minD = metric; + bestPair = { pA, pB }; + } + } + } + + if (bestPair) { + const { pA, pB } = bestPair; + // Force Build + const dx = pB.x - pA.x; + const dz = pB.z - pA.z; + const dLen = Math.sqrt(dx * dx + dz * dz); + // Guard 0 len + const nx = dLen > 0.001 ? dx / dLen : 1; + const nz = dLen > 0.001 ? dz / dLen : 0; + + // Get Spire Index for radius + const sRadA = spires[pA.debugSpireIdx].radius; + const sRadB = spires[pB.debugSpireIdx].radius; + + const startPt = this.getUncrowdedRimPoint(pA, nx, nz, 0.5, sRadA); + const endPt = this.getUncrowdedRimPoint(pB, -nx, -nz, 0.5, sRadB); + + if (startPt && endPt) { + const start3D = { x: startPt.x, y: pA.y, z: startPt.z }; + const end3D = { x: endPt.x, y: pB.y, z: endPt.z }; + + const centerA = spires[pA.debugSpireIdx]; + if ( + this.buildBridge( + start3D, + end3D, + { x: centerA.x, z: centerA.z }, + 0.0 + ) + ) { + pA.connections.push(startPt); + pB.connections.push(endPt); + + this.generatedAssets.bridges.push({ + fromSpire: pA.debugSpireIdx, + toSpire: pB.debugSpireIdx, + start: start3D, + end: end3D, + yd: Math.abs(end3D.y - start3D.y), + dist: dLen - pA.radius - pB.radius, + }); + } + } + } + } } } } /** * Tries to find a rim point near the ideal normal (nx, nz) that isn't crowded. - * Scans +/- 50 degrees. + * @param {number} pillarRadius - The radius of the central pillar to avoid clipping into. */ - getUncrowdedRimPoint(platform, nx, nz, penetration = 1.5) { + getUncrowdedRimPoint(platform, nx, nz, penetration = 1.5, pillarRadius = 3) { // Angles to try: 0, +20, -20, +40, -40 const angles = [0, 0.35, -0.35, 0.7, -0.7]; // Radians (approx 20, 40 deg) @@ -345,62 +809,288 @@ export class CrystalSpiresGenerator extends BaseGenerator { 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); + // Ensure we don't penetrate into the solid pillar + // Pillar is at center. We need dist > pillarRadius. + // Let's enforce min dist = pillarRadius + 2.5 (margin for unit center) + const validDist = Math.max( + platform.radius - penetration, + pillarRadius + 2.5 + ); + + const px = platform.x + rX * validDist; + const pz = platform.z + rZ * validDist; let crowded = false; + + // 1. Check existing connections (Hub Spacing) 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 + if (this.dist2D(px, pz, c.x, c.z) < 2.5) { + // INCREASED from 2.0 to 2.5 to prevent "Wall Effect" crowded = true; break; } } if (!crowded) { + // 2. Check Pillar Collision (Don't clip through THIS spire's center) + // Spire center is platform center (mostly) + // Ensure the path OUTWARDS doesn't clip. + // Actually, bridges go OUT. So checking if (px,pz) is inside isn't enough. + // We need to check the path to the TARGET. + // We don't have target here, but we can assume generally outwards. + // But wait, the main issue is clipping *other* spires or the *current* spire if the angle is crazy. + // Since we are moving along the rim, we are safe from *this* pillar unless we go backwards. + // The main clipping check needs to happen in connectSpires where we have both points. + return { x: px, z: pz }; } } return null; } - buildBridge(start, end) { - // 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) - ); + // A* Removed - // FAILSAFE: Reject bridges that are too short (touching/overlapping) - if (dist < 2.0) return; + /** + * Generates a bridge using Quadratic Bezier Curve. + * STRICT ADDITIVE LOGIC (CoA 2): Cannot overwrite non-zero or carve headroom. + * Returns true if successful, false if blocked. + */ + generateBezierBridge(pA, pB, bridgeDef) { + // 1. Calculate Edge Points (Anchors) + // Vector A->B + const dx = pB.x - pA.x; + const dz = pB.z - pA.z; + const dist = Math.sqrt(dx * dx + dz * dz); + if (dist < 0.1) return false; - // Supersample for solid path - const steps = Math.ceil(dist * 3); + const nx = dx / dist; + const nz = dz / dist; - const stepX = (end.x - start.x) / steps; - const stepY = (end.y - start.y) / steps; // Slope connection - const stepZ = (end.z - start.z) / steps; + // Radius offsets (Start at Rim) + const startX = pA.x + nx * (pA.radius - 0.5); + const startZ = pA.z + nz * (pA.radius - 0.5); + const endX = pB.x - nx * (pB.radius - 0.5); + const endZ = pB.z - nz * (pB.radius - 0.5); - let currX = start.x; - let currY = start.y; - let currZ = start.z; + // Control Point (Midpoint + Offsets) + const midX = (startX + endX) / 2; + const midZ = (startZ + endZ) / 2; + const midY = (pA.y + pB.y) / 2; + + // Arc logic: + // If short bridge, small arc. If long bridge, larger arc. + // Randomize slightly for organic feel. + const arcFactor = dist < 5.0 ? 0.5 : dist / 8.0; + const archHeight = this.rng.range(1, 4) * arcFactor; + + // Curve logic: + // Lateral offset relative to distance + const curveMag = this.rng.range(-dist / 4, dist / 4); + const cx = midX - nz * curveMag; + const cz = midZ + nx * curveMag; + const cy = midY + archHeight; + + const p1 = { x: cx, y: cy, z: cz }; // Control + const p0 = { x: startX, y: pA.y, z: startZ }; // Start + const p2 = { x: endX, y: pB.y, z: endZ }; // End + + // Voxelization + const steps = Math.ceil(dist * 2.5); // Fine sampling + const pathVoxels = []; + const visited = new Set(); for (let i = 0; i <= steps; i++) { - const tx = Math.round(currX); - const ty = Math.round(currY); - const tz = Math.round(currZ); + const t = i / steps; + const invT = 1 - t; - // Bridge Voxel ID 20 (Light Bridge) - // Only overwrite air - if (this.grid.getCell(tx, ty, tz) === 0) { - this.grid.setCell(tx, ty, tz, 20); + // Quadratic Bezier Formula + const x = invT * invT * p0.x + 2 * invT * t * p1.x + t * t * p2.x; + const y = invT * invT * p0.y + 2 * invT * t * p1.y + t * t * p2.y; + const z = invT * invT * p0.z + 2 * invT * t * p1.z + t * t * p2.z; + + const ix = Math.round(x); + const iy = Math.round(y); + const iz = Math.round(z); + const key = `${ix},${iy},${iz}`; + + if (!visited.has(key)) { + visited.add(key); + pathVoxels.push({ x: ix, y: iy, z: iz }); + } + } + + // Validation Loop (The "Simulation") + for (let i = 0; i < pathVoxels.length; i++) { + const v = pathVoxels[i]; + + // Bounds + if (!this.grid.isValidBounds(v.x, v.y, v.z)) return false; + + // Check COAs + const id = this.grid.getCell(v.x, v.y, v.z); + // Allow overwriting Air (0) or existing Bridge (20) or Teleporter (22) + // Fail if hitting Stone(1), Crystal(15), etc. + if (id !== 0 && id !== 20 && id !== 22) { + // Exception: Anchors. If we are very close to start/end, allows overlap (merged mesh) + // index check is rough. Distance check is better. + const dStart = + (v.x - p0.x) ** 2 + (v.y - p0.y) ** 2 + (v.z - p0.z) ** 2; + const dEnd = (v.x - p2.x) ** 2 + (v.y - p2.y) ** 2 + (v.z - p2.z) ** 2; + if (dStart > 4 && dEnd > 4) return false; // Collision mid-air } - currX += stepX; - currY += stepY; - currZ += stepZ; + // Headroom (Strict 2 blocks Air) + // We assume we can't carve. + const h1 = this.grid.getCell(v.x, v.y + 1, v.z); + const h2 = this.grid.getCell(v.x, v.y + 2, v.z); + // Headroom must be Empty or Bridge (if intersecting?? No, intersecting bridges is bad unless clearance) + // Spec: "Bridge MUST NOT be generated if it passes directly underneath another bridge with less than 2 voxels" + // So if h1 or h2 is Bridge(20), ABORT. + if (h1 !== 0 || h2 !== 0) return false; + + // Gradient Check (CoA 1) + if (i > 0) { + const prev = pathVoxels[i - 1]; + if (Math.abs(v.y - prev.y) > 1) return false; // Step too steep + } } + + // Placement Loop (Commit) + for (const v of pathVoxels) { + const id = this.grid.getCell(v.x, v.y, v.z); + if (id === 0) { + this.grid.setCell(v.x, v.y, v.z, 20); // Hard Light + } + } + + // Store Asset properties + bridgeDef.start = { x: p0.x, y: p0.y, z: p0.z }; + bridgeDef.end = { x: p2.x, y: p2.y, z: p2.z }; + this.generatedAssets.bridges.push(bridgeDef); + + return true; + } + + // Alias for compatibility if needed, or update callers to use generateBezierBridge + // Actually, callers verify logic. + // I will repurpose buildBridge to wrap this logic. + buildBridge(start, end, fromCenter = null, minDist = 2.0) { + // Wrapper for generateBezierBridge + // Simple Bezier logic for compatibility check + const p0 = start; + const p2 = end; + + const midX = (p0.x + p2.x) / 2; + const midZ = (p0.z + p2.z) / 2; + const midY = (p0.y + p2.y) / 2; + + const dx = p2.x - p0.x; + const dz = p2.z - p0.z; + const dist = Math.sqrt(dx * dx + dz * dz); + + const nx = dx / dist; + const nz = dz / dist; + + const arcFactor = dist < 5.0 ? 0.5 : dist / 8.0; + const archHeight = 3.0 * arcFactor; // Simplified random + const curveMag = 0; // Straight arch for simple builds (or random if rng available?) + // We don't have RNG access easily here unless we use this.rng + // Assume this.rng exists. + if (this.rng) { + const cm = this.rng.range(-dist / 4, dist / 4); + const ah = this.rng.range(1, 4) * arcFactor; + // Recalc + } + + // Re-use generateBezierBridge logic? + // "generateBezierBridge" requires pA, pB (Platforms). + // Here we have points. + // We'll duplicate logic briefly to ensure it works. + + const p1 = { x: midX, y: midY + archHeight, z: midZ }; + + const steps = Math.ceil(dist * 2.5); + const pathVoxels = []; + const visited = new Set(); + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const invT = 1 - t; + const x = invT * invT * p0.x + 2 * invT * t * p1.x + t * t * p2.x; + const y = invT * invT * p0.y + 2 * invT * t * p1.y + t * t * p2.y; + const z = invT * invT * p0.z + 2 * invT * t * p1.z + t * t * p2.z; + const ix = Math.round(x); + const iy = Math.round(y); + const iz = Math.round(z); + const key = `${ix},${iy},${iz}`; + if (!visited.has(key)) { + visited.add(key); + pathVoxels.push({ x: ix, y: iy, z: iz }); + } + } + + // VALIDATION + for (let i = 0; i < pathVoxels.length; i++) { + const v = pathVoxels[i]; + if (!this.grid.isValidBounds(v.x, v.y, v.z)) return false; + const id = this.grid.getCell(v.x, v.y, v.z); + if (id !== 0 && id !== 20 && id !== 22) { + const dStart = + (v.x - p0.x) ** 2 + (v.y - p0.y) ** 2 + (v.z - p0.z) ** 2; + const dEnd = (v.x - p2.x) ** 2 + (v.y - p2.y) ** 2 + (v.z - p2.z) ** 2; + if (dStart > 4 && dEnd > 4) return false; + } + const h1 = this.grid.getCell(v.x, v.y + 1, v.z); + const h2 = this.grid.getCell(v.x, v.y + 2, v.z); + if (h1 !== 0 || h2 !== 0) return false; + if (i > 0) { + if (Math.abs(v.y - pathVoxels[i - 1].y) > 1) return false; + } + } + + // PLACEMENT + for (const v of pathVoxels) { + if (this.grid.getCell(v.x, v.y, v.z) === 0) { + this.grid.setCell(v.x, v.y, v.z, 20); + } + } + return true; + } + + // Legacy Removed + + checkCylinderCollision(x1, z1, x2, z2, cx, cz, r) { + // Check if line segment (x1,z1)-(x2,z2) intersects circle (cx,cz,r) + // AND is not just touching the endpoints (which are on the rim) + + // Vector d = p2 - p1 + const dx = x2 - x1; + const dz = z2 - z1; + + // Vector f = p1 - center + const fx = x1 - cx; + const fz = z1 - cz; + + const a = dx * dx + dz * dz; + const b = 2 * (fx * dx + fz * dz); + const c = fx * fx + fz * fz - r * r; + + let discriminant = b * b - 4 * a * c; + + if (discriminant < 0) return false; // No intersection + + // Check if intersection is within segment (0 <= t <= 1) + discriminant = Math.sqrt(discriminant); + const t1 = (-b - discriminant) / (2 * a); + const t2 = (-b + discriminant) / (2 * a); + + // We ignore intersections at exactly t=0 or t=1 because valid bridges start/end on rims + // So we check if 0.05 < t < 0.95 + if ((t1 > 0.05 && t1 < 0.95) || (t2 > 0.05 && t2 < 0.95)) { + return true; + } + + return false; } dist2D(x1, z1, x2, z2) { @@ -472,4 +1162,120 @@ export class CrystalSpiresGenerator extends BaseGenerator { } } } + + /** + * Post-Generation Verification + * Replicates game movement logic to ensure every generated bridge is walkable. + * If a bridge fails (e.g. switchbacks, blockages), it is removed from assets. + */ + /** + * Ensure Connectivity via Flood Fill & Teleporters. + */ + ensureGlobalConnectivity(spires) { + console.log("Ensure Connectivity running..."); + if (!spires || spires.length === 0) return; + + const adj = new Map(); + const platById = new Map(); + const getPlatId = (sIdx, pIdx) => `${sIdx}:${pIdx}`; + + let nodeCount = 0; + spires.forEach((s, sIdx) => { + s.platforms.forEach((p, pIdx) => { + const id = getPlatId(sIdx, pIdx); + adj.set(id, { id, p, neighbors: new Set() }); + platById.set(id, p); + nodeCount++; + }); + }); + console.log(`Adjacency Graph Nodes: ${nodeCount}`); + + let edgeCount = 0; + this.generatedAssets.bridges.forEach((b) => { + if (b.fromPlatIdx !== undefined && b.toPlatIdx !== undefined) { + const idA = getPlatId(b.fromSpire, b.fromPlatIdx); + const idB = getPlatId(b.toSpire, b.toPlatIdx); + if (adj.has(idA) && adj.has(idB)) { + adj.get(idA).neighbors.add(idB); + adj.get(idB).neighbors.add(idA); + edgeCount++; + } + } else { + console.log("Bridge missing indices:", b); + } + }); + console.log(`Adjacency Graph Edges: ${edgeCount}`); + + const visited = new Set(); + const components = []; + + for (const [id, node] of adj) { + if (!visited.has(id)) { + const comp = []; + const q = [id]; + visited.add(id); + while (q.length > 0) { + const curr = q.shift(); + comp.push(curr); + adj.get(curr).neighbors.forEach((nId) => { + if (!visited.has(nId)) { + visited.add(nId); + q.push(nId); + } + }); + } + components.push(comp); + } + } + + console.log(`Connected Components: ${components.length}`); + + if (components.length > 1) { + components.sort((a, b) => b.length - a.length); + const targetComp = components[0]; + const targetNodeId = targetComp[0]; + const targetPlat = platById.get(targetNodeId); + + for (let i = 1; i < components.length; i++) { + const orphanComp = components[i]; + const orphanNodeId = orphanComp[0]; + const orphanPlat = platById.get(orphanNodeId); + + console.log( + `Linking Component ${i} (Size ${orphanComp.length}) to Main` + ); + this.placeTeleporter(targetPlat, orphanPlat); + } + } + } + + placeTeleporter(pA, pB) { + const findSpot = (p) => { + const r = p.radius - 1; + for (let x = Math.round(p.x - r); x <= Math.round(p.x + r); x++) { + for (let z = Math.round(p.z - r); z <= Math.round(p.z + r); z++) { + const y = Math.round(p.y); + const floor = this.grid.getCell(x, y, z); + const air = this.grid.getCell(x, y + 1, z); + if (floor !== 0 && air === 0) { + return { x, y: y + 1, z }; + } + } + } + return null; + }; + + const sA = findSpot(pA); + const sB = findSpot(pB); + + if (sA && sB) { + this.grid.setCell(sA.x, sA.y, sA.z, 22); + this.grid.setCell(sB.x, sB.y, sB.z, 22); + console.log( + `Placed Teleporter between (${sA.x},${sA.y},${sA.z}) and (${sB.x},${sB.y},${sB.z})` + ); + } else { + console.log("Failed to find spot for Teleporter"); + } + } } diff --git a/src/grid/VoxelManager.js b/src/grid/VoxelManager.js index faa93a1..9bdc53c 100644 --- a/src/grid/VoxelManager.js +++ b/src/grid/VoxelManager.js @@ -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 diff --git a/src/tools/map-visualizer.html b/src/tools/map-visualizer.html index e43bb21..c0b00b4 100644 --- a/src/tools/map-visualizer.html +++ b/src/tools/map-visualizer.html @@ -109,6 +109,11 @@ Apply Textures + +
diff --git a/src/tools/map-visualizer.js b/src/tools/map-visualizer.js index 66b9a58..64e87b4 100644 --- a/src/tools/map-visualizer.js +++ b/src/tools/map-visualizer.js @@ -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();