项目坍缩浮岛立体化

立体化

把一张 2D 地形图挤出成一座带锥形底座的浮空岛。关键在"深度平滑"—— 漏了它底面就是一排悬空的尖牙;另一个 bug 则要等到能点击编辑地形才暴露。

已完成

职责

输入是求解器的产物(每格是什么地形),输出是一堆“柱子”: 每根柱子知道自己在地表的什么高度、底下延伸到什么深度。画的事交给渲染器。

一根柱子有什么

{ 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;
}