职责
输入是求解器的产物(每格是什么地形),输出是一堆“柱子”:
每根柱子知道自己在地表的什么高度、底下延伸到什么深度。画的事交给渲染器。
一根柱子有什么
{ x, z, lv, h, bottom, deco, ridge }
| 字段 |
含义 |
lv |
这一格是什么地形(1 水 / 2 沙 / 3 草 / 4 林 / 5 岩) |
h |
顶面的 y 坐标(地表高度) |
bottom |
底面的 y 坐标(锥底) |
deco |
这一格要不要长树/盖房的骰子,逐格固定 |
ridge |
是不是全岛最高的那几层(岩脊顶部) |
除了 cols,还会返回一张 at[] 反查表((x,z) → cols 下标)。
cols 是“跳过深渊格”的紧凑数组,下标不等于 z*n+x —— 点击编辑时命中的是格子键,
要靠这张表才找得回对应的柱子。
三遍扫描
顺序不能换 —— 第二遍依赖第一遍的原始深度,第三遍依赖第二遍的平滑结果。
第一遍 · 地表高度
| 地形 |
高度 |
说明 |
| 水域 |
0 |
海平面,也是全岛基线 |
| 沙滩 |
1 |
贴着水 |
| 草地 |
1 或 2 |
逐格骰子,做出起伏 |
| 林地 |
2 或 3 |
比草地高一档 |
| 岩石 |
3 ~ 5 |
逐格骰子,形成参差的岩脊 |
同一个地形给两档高度、用逐格骰子选,是“看起来像天然地形”和“看起来像一块块的色带”的分界。
第二遍 · 深度平滑 ⚠️
这一条最容易被漏掉。
锥形底座的原始深度是 9.5 × (1 − r) × 逐格抖动。抖动让地面自然,但相邻两列的锥深
可能差三四个体素 —— 从斜下方看,底面就是一排悬空的尖牙。
// 3×3 圆盘(跳掉四个角),自己的权重加倍
for (dz, dx in [-1,1]) if (dx && dz) continue;
⚠️ 平滑只覆盖 D[](深度),不碰 H[](地表高度)。
这是刻意的:改了地表,岛的形状和地貌分布就全变了;只改底座深度,轮廓一点都不动。
换来的收益是底面从“尖牙”变成一条平滑收拢的锥线。
第三遍 · 组装
bottom = min(−round(D[i]), h − 1)
底面只由「离岛心多远」决定 —— 一个纯几何的锥体,和地表高度无关。
于是所有柱子共享同一块岩体:岛脚是一条连续的锥线,任何一根柱子都不会悬空,
也不会插进隔壁柱子里。
后面那个 min(…, h − 1) 是保底,保证每根柱子至少有自己那一层地表。
它顺带解决了近岸的水:水的高度是 0,h − 1 就是 −1,水格自动获得至少 1 格的厚度,
不会薄成一张贴在地上的蓝纸。
这里躺着一个只有编辑器才能发现的 bug
早先的公式是“地层厚度 = 深度基线 + 地表高度差”,写出来是这样:
// 旧版
depth = max(lv === WATER ? 1 : 0, round(D[i]) + (h - maxH));
bottom = h - 1 - depth;
| 版本 |
天然地形 |
手动刷一根高岩柱 |
旧 · max(0, …) 夹取 |
看着完全正常 |
整根柱子悬在半空 |
现 · min(−round(D), h − 1) |
正常 |
正常 |
关键在于那个 max(0, …)。它为什么在天然地形下永远不触发?
因为岩石总是长在岛心,而岛心的锥体本来就深 —— round(D) + (h − maxH) 始终是正数,
夹取形同虚设。等到能手动点地形,在贴近岛缘处刷一根高岩柱,
h − maxH 变成负数被夹到 0,底面被抬到 h − 1,柱子就离开岩体浮了起来。
这是**“未被验证过的不变量”**最典型的例子:代码里的 max(0, …) 看着像在防御,
实际是把“所有柱子同底”这个前提悄悄破坏了,而当时没有任何输入能走到那一支。
教训不是“要小心”,而是:当一段防御性代码的触发条件在正常输入下永远为假时,
要么它错了,要么它的前提没被验证过 —— 而这两件事都不会自己浮出来。
已知的坑
| 坑 |
症状 |
| 不做深度平滑 |
底面是一排悬空的尖牙 |
| 平滑时把地表高度也一起平了 |
岛的形状变圆滑、地貌层次被抹平,看着“没味” |
| 底面公式里掺进地表高度 |
贴近岛缘的高格悬空(上面那一节) |
| 水格的保底去掉 |
近岸的水变成 0 厚的平面,看起来像贴在地上的蓝色纸 |
at[] 反查表漏建 |
点击编辑时改的是另一根柱子 —— 而且是“看起来没反应”的那种错 |
源码
build.js
/**
* 立体化 —— 把一张 2D 地形图挤出成一座浮空岛。
* ---------------------------------------------------------------------------
* 每个非深渊格子变成一根竖直的"柱子":地表层(1 格)+ 下面的地幔。
* 柱子怎么排、每个面什么颜色,就是这一层全部的事;负责画的是 iso-renderer。
*
* 三个可调的地方,顺序不能换:
* ① 地表高度:按地形给 0–4,岩石最高、水最低。
* ② 锥形底座:深度 ∝ (1 - 离岛心的距离)。这是"浮空"的观感来源。
* ③ 深度平滑:**这一条最容易被漏掉**。不平滑的话,相邻两列的锥深会差
* 好几个体素,从下面看就是一排悬空的尖牙。3×3 圆盘平滑只动"底下多深",
* 完全不碰地表地形,所以岛的轮廓一点没变。
*/
import { VOID, WATER, SAND, GRASS, FOREST, ROCK } from './solver.js';
/**
* @param {{n:number, cells:Int8Array, seed:number}} solution 求解结果
* @returns {{cols:Array, count:Object, n:number, maxH:number}}
*/
export function buildIsland(solution){
const n = solution.n, cells = solution.cells, s = solution.seed;
const c = (n - 1) / 2;
const cols = [];
const count = { [WATER]:0, [SAND]:0, [GRASS]:0, [FOREST]:0, [ROCK]:0 };
const H = new Int8Array(n * n); // 地表高度
const RAWD = new Float32Array(n * n); // 未平滑的深度
let maxH = 1;
// ---- 第一遍:地表高度 + 原始锥深 ----
for (let z = 0; z < n; z++){
for (let x = 0; x < n; x++){
const i = z * n + x;
const lv = cells[i];
if (lv === VOID) continue;
count[lv] = (count[lv] || 0) + 1;
const r = Math.min(1, Math.hypot(x - c, z - c) / c);
const j = hash01(x, z, s + 3);
let h;
switch (lv){
case WATER: h = 0; break;
case SAND: h = 1; break;
case GRASS: h = j > 0.74 ? 2 : 1; break;
case FOREST: h = j > 0.48 ? 3 : 2; break;
default: h = 3 + Math.round(j * 1.7); break; // ROCK:3~5 的岩脊
}
H[i] = h;
if (h > maxH) maxH = h;
const taper = Math.max(0, 1 - r);
RAWD[i] = 9.5 * taper * (0.68 + 0.62 * hash01(x, z, s + 29));
}
}
// ---- 第二遍:深度平滑(只动底座,不动地表)----
const D = new Float32Array(n * n);
for (let z = 0; z < n; z++){
for (let x = 0; x < n; x++){
const i = z * n + x;
if (cells[i] === VOID) continue;
let sum = 0, wsum = 0;
for (let dz = -1; dz <= 1; dz++){
for (let dx = -1; dx <= 1; dx++){
if (dx && dz) continue; // 圆盘,不是方块
const nx = x + dx, nz = z + dz;
if (nx < 0 || nx >= n || nz < 0 || nz >= n) continue;
const j = nz * n + nx;
const w = (dx === 0 && dz === 0) ? 2 : 1; // 自己权重加倍
sum += RAWD[j] * w; wsum += w;
}
}
D[i] = wsum ? sum / wsum : RAWD[i];
}
}
// ---- 第三遍:装成柱子 ----
// 底面**只由「离岛心多远」决定** —— 一个纯几何的锥体,与地表高度无关。
// 这样所有柱子共享同一块岩体:岛脚是一条连续的锥线,不会出现某根柱子
// 悬空或者"插进别的柱子里"。
//
// ⚠️ 这里踩过一个只有"点击编辑地形"才暴露得出来的坑,经验值得记下来:
// 早先写的是 `depth = max(水 ? 1 : 0, round(D[i]) + (h - maxH)); bottom = h - 1 - depth`。
// 那个 `max(0, …)` 夹取在**天然地形**下永远不触发 —— 岩石总长在岛心,
// 而岛心的锥体本来就深。于是"同底"这个前提一直没被验证过。
// 直到能手动刷地形:在贴近岛缘处刷一根高岩柱,`h - maxH` 变成负数被夹到 0,
// 底面随之被抬到 `h - 1`,整根柱子**悬在半空**。
//
// 再取 `min(…, h - 1)` 是为了保证每根柱子至少有自己那一层地表(水格因此
// 至少厚 1,保住近岸"水"的厚度)。
//
// cols 是"跳过深渊"的紧凑数组,下标 ≠ z*n+x,所以要另存一张反查表:
// 点击编辑时命中的是格子键 z*n+x,得靠它找回对应的那根柱子。
const at = new Int32Array(n * n).fill(-1);
for (let z = 0; z < n; z++){
for (let x = 0; x < n; x++){
const i = z * n + x;
const lv = cells[i];
if (lv === VOID) continue;
const h = H[i];
const bottom = Math.min(-Math.round(D[i]), h - 1);
at[i] = cols.length;
cols.push({
x, z, lv, h, bottom,
deco: hash01(x, z, s + 71), // 要不要长树/盖房子
ridge: h >= maxH - 1, // 岩脊顶部
});
}
}
return { cols, count, n, maxH, at };
}
function hash01(x, y, s){
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(s | 0, 1274126177)) | 0;
h = (h ^ (h >>> 13)) | 0;
h = Math.imul(h, 1274126177) | 0;
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}