项目坍缩浮岛坍缩求解器

坍缩求解器

兼容表、香农熵、约束传播与观测顺序 —— 整个工程的核心算法,也是唯一"会算错"的地方。

已完成

职责

给定一张“谁可以挨着谁”的规则表和一个随机种子,在 N×N 网格上求出一组自洽的地形填法, 并记录每一格是在第几步被观测的。

最后那个“记录顺序”是关键 —— 见下面「为什么返回顺序而不是结果」。

三样东西

名字 是什么 为什么长这样
ADJ_TEXT 人写的相邻兼容表,6 个数组 可读性优先。它是这个作品唯一的“物理定律”,应该让人一眼看懂
ADJ 编译后的位掩码 Uint8Array(6) 传播是热路径,一次 & 判一整组,比查数组快一个量级
w 每格每地形的权重 Float32Array(n²×6) 权重依赖位置(离岛心多远),没法全局共享,只能逐格展开

⚠️ ADJ 编译完强制对称一次。手写兼容表极容易写成单向(“水可以挨草”却漏了“草可以挨水”), 单向表的症状是地形分布莫名偏向某一侧,很难反查。所以在编译期一次性兜住。

主循环:观测 → 传播

init()           圆外钉成深渊,圆内铺好逐格权重
seedPropagate()  把所有"已确定"的格子推进队列,跑一次全量传播
loop:
  step()         挑熵最低的格子 → 按权重掷出地形 → propagate()
  step() 返回 'fail' → 整轮作废,换种子重来

熵怎么算

香农熵 H = ln(Σw) − (Σw·lnw)/Σw。候选越杂熵越高。

⚠️ 只剩一个候选的格子熵记作 -1,不是算出 0。因为 0 比任何“候选很少但仍混沌”的格子还低, 会让求解器把大量时间花在“其实已经确定、只是还没标记”的格子上。用 -1 让它们排在最前面清掉。

传播是灵魂

let allowed = 0;
for (let b = 0; b < 6; b++) if (ci & (1 << b)) allowed |= ADJ[b];
const nc = allowed & this.cand[j];
if (!nc) return false;          // 交集为空 = 矛盾
if (nc !== this.cand[j]) { this.cand[j] = nc; stack.push(j); }

“我自己能是什么” → 推出“邻居还能是什么” → 与邻居当前候选求交 → 变了就继续推。 定下一格会连带收紧一整片,这才是 WFC 和随机刷格子的分水岭。

交集为空就是矛盾。矛盾在这里是原理性的,不是 bug —— 所以外层是“换种子重试”而不是“修 bug”。

为什么返回顺序而不是结果

求解阶段允许失败重试,播放阶段必须一次成功。把两者拆开之后:

求解 播放
要不要快 越快越好(实测 3–10 ms) 按用户设的速度慢慢来
允许失败吗 允许,静默换种子重试(最多 90 次) 绝不 —— 用户的进度条不能卡住
用户看得到吗 看不到 全程可见

所以 solve() 返回的是 { order:[{i,lv,ent}], cells, seed }: order 给动画用,cells 给最终地形用,seed 给“这条记录到底是用哪个种子解出来的”用 (重试过的种子和传入的种子不是同一个,必须回传,否则种子复现就对不上)。

岛的轮廓

init() 里每个格子的“是不是岛内”由三段正弦叠加的半径决定:

const edge = 0.850
  + 0.070 * Math.sin(ang * 3 + s * 0.73)
  + 0.042 * Math.sin(ang * 5 - s * 1.31)
  + 0.026 * Math.sin(ang * 8 + s * 2.17);
if (rad > edge) → 钉成深渊
现象 原因
振幅调到 0.10 / 0.06 / 0.04 以上 岛碎成几片条带。全量传播会把最外圈的深渊约束一层层往里推,形状折得太狠时整圈被推穿
振幅全设 0 岛是一个完美圆,环带整齐得像同心圆,假
三段频率用 3 / 5 / 8 三与八互质,叠出来的轮廓不会出现明显重复的波纹

环带本身(哪一圈是什么地形)不在这里决定,在 biasFor() 的权重里 —— 见「权重模型」。 轮廓只管“哪里是岛、哪里是海”。

权重模型

biasFor(v, r, jitter) 按归一化半径 r 给每种地形加权,峰位大致是:

岩石 0.06 ── 林地 0.34 ── 草地 0.56 ── 沙滩 0.78 ── 水域 1.00

⚠️ 这不算作弊。 在 WFC 里按格子调权重是标准做法:它只改变“随机挑一个候选”时的倾向, 约束传播该拦的照样拦。事后按距离硬刷地形才会破坏局部一致性 —— 那是在结果上贴一张同心圆。

源码

solver.js

                /**
 * 波函数坍缩求解器 —— 整个工程的核心,与页面里跑的是同一份逻辑。
 * ---------------------------------------------------------------------------
 * 输入是"每种地形能挨着谁"的规则表和一个随机种子;
 * 输出是**每一格被观测(坍缩)的顺序**,而不是一次性地给出一张地图。
 *
 * 为什么要把顺序返回出去
 *   标准 WFC 是"求解即成品",用户只看到结果。但这个作品想让人看见"坍缩"本身,
 *   所以求解和播放被拆开:求解阶段爱怎么回溯、重试都行,播放阶段按记录好的顺序
 *   一格一格点亮。动画因此永远跑得完 —— 失败的尝试根本不会进入播放列表。
 */

/* ---------------------------------------------------------------- 基础工具 */

/** 32 位确定性随机数(mulberry32)—— 同一个种子永远得到同一座岛 */
export function mulberry32(a){
  return function(){
    a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** 位置哈希 → [0,1)。用于"每格固定"的扰动:同一格每次渲染的抖动都一样,不会闪 */
export 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;
  h = (h ^ (h >>> 16)) >>> 0;
  return h / 4294967296;
}

/* ------------------------------------------------------------ 地形与约束 */

export const VOID = 0, WATER = 1, SAND = 2, GRASS = 3, FOREST = 4, ROCK = 5;

/**
 * 相邻兼容表:key 的地形可以和数组里的地形紧挨着。
 * 这张表就是整个工程的"规则"—— 求解器只负责在这套规则下找一组自洽的填法。
 *
 *   water 能挨 grass          → 内陆可以出现湖
 *   sand  能挨 rock           → 海边可以立礁石
 *   water 不能挨 forest/rock  → 海岸线保留结构,不会糊成一团
 *   void  只挨 void/water     → 岛的外缘一定是水,于是自然形成"环水的岛"
 */
export const ADJ_TEXT = {
  void:   ['void','water'],
  water:  ['void','water','sand','grass'],
  sand:   ['water','sand','grass','rock'],
  grass:  ['water','sand','grass','forest'],
  forest: ['grass','forest','rock'],
  rock:   ['sand','forest','rock'],
};

/** 兼容表编译成位掩码:ADJ[a] 的第 b 位 = 1 表示 a 与 b 可相邻 */
export const ADJ = new Uint8Array(6);
for (let a = 0; a < 6; a++){
  let m = 1 << a;
  for (let b = 0; b < 6; b++){
    if (ADJ_TEXT[LEVEL_KEYS[a]].indexOf(LEVEL_KEYS[b]) >= 0) m |= 1 << b;
  }
  ADJ[a] = m;
}
// 强制对称 —— 手写兼容表很容易写成单向,在这里一次性兜住
for (let a = 0; a < 6; a++){
  for (let b = 0; b < 6; b++){
    if ((ADJ[a] >> b) & 1) ADJ[b] |= 1 << a;
  }
}

export const LEVEL_KEYS = ['void','water','sand','grass','forest','rock'];

/* --------------------------------------------------------------- 权重模型 */

/** 基准权重:草地与水是主体,岩石最少 —— 决定岛的整体地貌比例 */
export const BASE_W = [0, 62, 34, 72, 48, 24];

function gauss(x, mu, sigma){
  const d = (x - mu) / sigma;
  return Math.exp(-0.5 * d * d);
}

/**
 * 位置权重:按"离岛心的距离"给每种地形加权。高斯峰位大致是
 * 中心岩石 → 林地 → 草地 → 沙滩 → 外围水域。
 *
 * 这不是事后修改地形(那会破坏局部一致性),而是 WFC 里的标准做法:
 * 只改"随机挑一个候选"时的倾向,约束传播该拦的照样拦。
 * 没有它,环带会整齐得像同心圆;配上逐格抖动,同一个规则也能长出千变万化的岛。
 */
export function biasFor(v, r, jitter){
  let b = 1;
  switch (v){
    case WATER:  b = 0.14 + 3.20 * gauss(r, 1.00, 0.18); break;
    case SAND:   b = 0.30 + 3.80 * gauss(r, 0.78, 0.16); break;
    case GRASS:  b = 0.30 + 1.75 * gauss(r, 0.56, 0.26); break;
    case FOREST: b = 0.16 + 2.60 * gauss(r, 0.34, 0.20); break;
    case ROCK:   b = 0.05 + 4.60 * gauss(r, 0.06, 0.30); break;
  }
  return b * (0.62 + 0.76 * jitter);
}

/* -------------------------------------------------------------- 求解器本体 */

const DIRS = [[1,0],[-1,0],[0,1],[0,-1]];

export class Solver {
  /** @param n 网格边长  @param seed 随机种子 */
  constructor(n, seed){
    this.n = n;
    this.seed = seed | 0;
    this.rng = mulberry32(this.seed);
    this.total = n * n;
    this.cand  = new Uint8Array(this.total);        // 候选地形位掩码
    this.fixed = new Uint8Array(this.total);        // 1 = 已确定
    this.lv    = new Int8Array(this.total);         // 确定后的地形
    this.w     = new Float32Array(this.total * 6);  // 每格每地形的权重
    this.lastIdx = -1; this.lastLv = 0; this.lastEnt = 0;
  }

  idx(x, z){ return z * this.n + x; }

  /**
   * 掩码 + 权重。
   * 圆外的格子被**钉成深渊**(掩码只剩 void、标记为已确定),
   * 圆内按到岛心的距离铺一套倾向权重。岛的轮廓半径被三组不同频率的正弦扰动,
   * 所以每个种子给出不同的岛形 —— 但振幅刻意压小,太大就会把岛撕成条带。
   */
  init(){
    const n = this.n, c = (n - 1) / 2, s = this.seed;
    const FULL = 0b111110;                       // level 1..5
    for (let z = 0; z < n; z++){
      for (let x = 0; x < n; x++){
        const i = this.idx(x, z);
        const dx = (x - c) / c, dz = (z - c) / c;
        const rad = Math.sqrt(dx * dx + dz * dz);
        const ang = Math.atan2(dz, dx);

        const edge = 0.850
          + 0.070 * Math.sin(ang * 3 + s * 0.73)
          + 0.042 * Math.sin(ang * 5 - s * 1.31)
          + 0.026 * Math.sin(ang * 8 + s * 2.17);

        if (rad > edge){
          this.cand[i]  = 1 << VOID;
          this.fixed[i] = 1;
          this.lv[i]    = VOID;
        } else {
          this.cand[i] = FULL;
        }

        // 用"实际半径 / 岛缘半径"归一化,岛的胖瘦才不会影响地貌比例
        const r = Math.min(1, rad / edge);
        const jit = hash01(x, z, s + 13);
        for (let v = 1; v <= 5; v++){
          this.w[i * 6 + v] = BASE_W[v] * biasFor(v, r, jit);
        }
      }
    }
  }

  /** 把已确定的格子全部推进约束队列,跑一次全量传播 */
  seedPropagate(){
    const stack = [];
    for (let i = 0; i < this.total; i++) if (this.fixed[i]) stack.push(i);
    return this.propagate(stack);
  }

  /**
   * 约束传播(AC-3 风格):
   * 取出队列里的格子,算出"它的邻居们还允许保留哪些地形",与邻居的候选求交;
   * 交集变了就把邻居也推进队列继续传播。交集为空 = 这组填法矛盾,整轮作废。
   *
   * 这是 WFC 和"随机刷格子"的根本区别:改一格会连带收紧一整片邻居。
   */
  propagate(stack){
    const n = this.n;
    while (stack.length){
      const i = stack.pop();
      const ci = this.cand[i];
      if (!ci) return false;
      const x = i % n, z = (i / n) | 0;

      for (let d = 0; d < 4; d++){
        const nx = x + DIRS[d][0], nz = z + DIRS[d][1];
        if (nx < 0 || nx >= n || nz < 0 || nz >= n) continue;   // 出界 = 无约束
        const j = this.idx(nx, nz);

        let allowed = 0;
        for (let b = 0; b < 6; b++) if (ci & (1 << b)) allowed |= ADJ[b];

        const nc = allowed & this.cand[j];
        if (!nc) return false;
        if (nc !== this.cand[j]){
          this.cand[j] = nc;
          stack.push(j);
        }
      }
    }
    return true;
  }

  /** 香农熵:候选集合越"杂"熵越高。求解器永远先啃熵最低的那一格 */
  entropy(i){
    let sum = 0, logsum = 0;
    const base = i * 6;
    for (let v = 1; v <= 5; v++){
      if (this.cand[i] & (1 << v)){
        const w = this.w[base + v] || 1e-4;
        sum += w; logsum += w * Math.log(w);
      }
    }
    if (sum <= 0) return 0;
    return Math.log(sum) - logsum / sum;
  }

  /**
   * 观测一格:在所有"熵最低"的格子里随机挑一个,按权重掷出一种地形。
   * 只剩一个候选的格子熵记作 -1 —— 它其实已经确定了,优先处理掉。
   */
  step(){
    let best = Infinity, ties = [];
    for (let i = 0; i < this.total; i++){
      if (this.fixed[i]) continue;
      const c = this.cand[i];
      const e = (c & (c - 1)) === 0 ? -1 : this.entropy(i);
      if (e < best - 1e-9){ best = e; ties.length = 0; ties.push(i); }
      else if (e <= best + 1e-9) ties.push(i);
    }
    if (!ties.length) return false;              // 全部观测完毕

    const i = ties[(this.rng() * ties.length) | 0];
    this.lastEnt = this.entropy(i);

    const base = i * 6;
    let total = 0;
    for (let v = 1; v <= 5; v++) if (this.cand[i] & (1 << v)) total += this.w[base + v];

    let pick = 1, acc = this.rng() * total;
    for (let v = 1; v <= 5; v++){
      if (this.cand[i] & (1 << v)){
        acc -= this.w[base + v];
        if (acc <= 0){ pick = v; break; }
      }
    }

    this.cand[i]  = 1 << pick;
    this.fixed[i] = 1;
    this.lv[i]    = pick;
    this.lastIdx  = i;
    this.lastLv   = pick;

    return this.propagate([i]) ? true : 'fail';
  }
}

/**
 * 求解并返回坍缩顺序。
 * 失败就叫下一个种子重试 —— 规则很宽松,实测几乎总是一次就过;
 * 重试之所以必要,是因为"矛盾"在 WFC 里是原理性的,不是 bug。
 *
 * @returns {{n:number, order:{i:number,lv:number,ent:number}[], cells:Int8Array, seed:number}|null}
 */
export function solve(n, seed){
  for (let attempt = 0; attempt < 90; attempt++){
    const s = (seed + attempt * 7919) | 0;
    const solver = new Solver(n, s);
    solver.init();
    if (!solver.seedPropagate()) continue;

    const order = [];
    let ok = true;
    for (;;){
      const r = solver.step();
      if (r === false) break;
      if (r === 'fail'){ ok = false; break; }
      order.push({ i: solver.lastIdx, lv: solver.lastLv, ent: solver.lastEnt });
    }
    if (ok && order.length) return { n, order, cells: solver.lv, seed: s };
  }
  return null;
}