算法说明 / 交互提示(含核心代码)
0 · 一句话总览
页面在做的是一件明确的事:把一堆可 90° 旋转的矩形物品,分配进若干个体积固定、尺寸各不相同的容器里,让尽量多(或尽量大)的物品放下,并输出一份人能照着摆的操作手册。它不是"最少用几个箱子"的经典装箱问题——容器数量、每个容器的宽高都是你给定的输入,所以这是一个固定异构容器的二维多背包问题,装不下时必须做取舍。
1 · 输入、变量与目标
| 符号 | 含义 | 取值 |
|---|---|---|
| I | 物品集合,每件物品 i 有 (wᵢ, hᵢ) 与身份 id | 1 ≤ wᵢ, hᵢ ≤ 20 |
| B | 容器集合,第 k 个容器有 (Wₖ, Hₖ) | 仓库 9×8~16;背包 1~5 × 1~9 |
| p | 一个摆放:物品 id、所在容器、左上角 (x, y)、朝向 rot | x, y 为整数格坐标 |
硬约束(任何时刻都不能违反):
- 不越界:0 ≤ x 且 x + w ≤ Wₖ,0 ≤ y 且 y + h ≤ Hₖ;
- 不重叠:任意两个已放置物品的格子集合互不相交;
- 朝向只有两种:原样 (w, h) 或旋转 90° 后的 (h, w);
- 物品不可堆叠、不可裁剪、坐标必须是整数格;
- 尺寸/类型白名单(可选,仅仓库模式):容器可声明"只接收哪些尺寸、哪些类型",不在名单里的一律不能进入该容器。尺寸按物品原始尺寸匹配、旋转 90° 等价(勾了 2×5,则 2×5 与 5×2 都算);类型取物品的类型标签(藏品/装备/武器/药品/弹药,缺省视为藏品)。两张名单同时生效,物品必须同时满足。
软目标(按字典序比较,越靠前的优先级越高):
件数优先: ( 放入件数 → 占用总面积 → 总占用行数↓ → 靠前容器越满 ) 面积优先: ( 占用总面积 → 放入件数 → 总占用行数↓ → 靠前容器越满 )
用"字典序 + 多级目标"而不是加权求和,是为了避免给不同量纲调权重:件数永远压过面积,面积永远压过行数,只在同分时才看下一级。
2 · 数据结构:位图 + 自由矩形表
每个容器内部维护两套互相印证的结构,它们决定了单次试排能有多快。
2.1 占用位图(O(1) 单格查询,O(面积) 区域判定)
把二维格子拉平成一维字节数组,1 表示已占用。所有"会不会压到别人"的判断都落在它上面。
const cells = new Uint8Array(W * H); // 0 = 空,1 = 已占用
const at = (x, y) => cells[y * W + x]; // 常数时间取一格
function markCells(cells, W, p, v) { // 落子/擦除:把一件物品覆盖的格子整片写值
for (let j = 0; j < p.h; j++)
for (let i = 0; i < p.w; i++)
cells[(p.y + j) * W + p.x + i] = v;
}
function regionFree(cells, W, H, x, y, w, h) { // 一块矩形是否完全空着且不越界
if (x < 0 || y < 0 || x + w > W || y + h > H) return false; // 越界
for (let j = 0; j < h; j++)
for (let i = 0; i < w; i++)
if (cells[(y + j) * W + x + i]) return false; // 压到别人
return true;
}
2.2 自由矩形表(MaxRects)
位图回答"这里能不能放",自由矩形表回答"下一个候选落点在哪"。它维护一张仍未被占用的矩形清单,初始只有整块容器;每放下一件,与它相交的自由矩形被切成最多 4 块(上/下/左/右),并删除被其它自由矩形完全包含的冗余块。
let free = [{ x: 0, y: 0, w: W, h: H }]; // 一开始整个容器都是空的
function splitFree(free, u) { // u = 刚放下那件物品所占的矩形
const out = [];
for (const f of free) {
const apart = u.x >= f.x + f.w || u.x + u.w <= f.x ||
u.y >= f.y + f.h || u.y + u.h <= f.y;
if (apart) { out.push(f); continue; } // 不相交:原样保留
if (u.y > f.y) out.push({ x: f.x, y: f.y, w: f.w, h: u.y - f.y }); // 上
if (u.y + u.h < f.y + f.h) out.push({ x: f.x, y: u.y + u.h, w: f.w, h: f.y + f.h - (u.y + u.h) }); // 下
if (u.x > f.x) out.push({ x: f.x, y: f.y, w: u.x - f.x, h: f.h }); // 左
if (u.x + u.w < f.x + f.w) out.push({ x: u.x + u.w, y: f.y, w: f.x + f.w - (u.x + u.w), h: f.h }); // 右
}
return pruneFree(out); // 去掉被其它自由矩形完全包住的块
}
为什么要 prune:不做剪枝,自由矩形会随放置次数线性膨胀,比较候选点的时间随之爆炸;剪掉被包含的块后,自由矩形数量在实战实例里通常维持在物品数的同一量级,单次试排才可能是微秒级。
3 · 一次完整构造:packMulti
给定一个物品顺序和一种落点口味,从头到尾放一遍,绝不回头、绝不回溯。这就是求解器最基本的一步,也是所有搜索的基础。实际代码拆成两层:makeStates() 建初始状态(可带"沿用上次摆放"的固定物品),packInto() 在上面跑装箱;packMulti() 只是把两者串起来的一个入口。
function packMulti(order, bins, heur, allowRotate, ordered, pinned) {
return packInto(makeStates(bins, pinned), order, heur, allowRotate, ordered);
}
function packInto(states, order, heur, allowRotate, ordered) {
const unplaced = [];
for (const it of order) { // 按顺序取物品
let chosen = null;
if (ordered) { // 策略 A:顺序装填:按优先级试容器(受限 → 万能 → 主仓库)
for (const bi of binPriority(states)) {
const st = states[bi];
if (!sizeAllowed(st.allow, it)) continue; // 白名单不收这个尺寸 → 整个容器跳过
let best = null;
for (const f of st.free) // 遍历该容器所有自由矩形
for (const o of rotsFor(it, allowRotate)) { // 两种朝向
if (o.w > f.w || o.h > f.h) continue; // 塞不进这块,跳过
const sc = fitScore(f, o.w, o.h, heur, st.cells, st.width, st.height);
if (!best || cmpArr(sc, best.sc) < 0) best = { f, o, sc }; // 只留更好的
}
if (best) { chosen = Object.assign({ st }, best); break; } // 这个容器放得下就不再往后看
}
} else { // 策略 B:全局最优
let best = null;
for (const st of states)
for (const f of st.free)
for (const o of rotsFor(it, allowRotate)) {
if (o.w > f.w || o.h > f.h) continue;
const sc = fitScore(f, o.w, o.h, heur, st.cells, st.width, st.height);
sc.push(states.indexOf(st)); // 同分时优先放进靠前的容器
if (!best || cmpArr(sc, best.sc) < 0) best = Object.assign({ st }, { f, o, sc });
}
chosen = best; // 所有容器一起比,位置最优者胜
}
if (!chosen) { unplaced.push(it); continue; } // 哪个容器都放不下 → 记入"未放置",不阻塞后续物品
const p = { id: it.id, name: it.name, w: chosen.o.w, h: chosen.o.h,
rot: chosen.o.rot, x: chosen.f.x, y: chosen.f.y, binId: chosen.st.id };
chosen.st.placed.push(p);
markCells(chosen.st.cells, chosen.st.width, p, 1); // 位图记账
chosen.st.free = splitFree(chosen.st.free, p); // 自由矩形表记账
}
return { states, unplaced };
}
关键性质:整个过程的复杂度是 O(N · B · F · 2 · w̄h̄),其中 N 是物品数、B 是容器数、F 是平均自由矩形数、w̄h̄ 是平均物品面积(用于 markCells)。没有递归、没有回溯、没有穷举,所以"放一遍"在几十件物品时只要几百微秒——这是 0.01 秒档能够成立的根本原因。
4 · 五种落点口味:fitScore
同一块物品放进同一个自由矩形,靠左还是靠上、贴边还是留缝,会显著影响后续能不能塞得下。评分函数返回一个数组,按字典序比较,越小越优;数组前两项是"废料度量",后两项用坐标做同分并列时的收敛方向(保证确定性、可复现)。
function fitScore(f, w, h, heur, cells, W, H) {
const rw = f.w - w, rh = f.h - h; // 放下之后这块空位剩多宽、剩多高
switch (heur) {
case 'bssf': return [Math.min(rw, rh), Math.max(rw, rh), f.y, f.x]; // 最贴合短边
case 'blsf': return [Math.max(rw, rh), Math.min(rw, rh), f.y, f.x]; // 最贴合长边
case 'baf': return [f.w * f.h - w * h, Math.min(rw, rh), f.y, f.x]; // 最小剩余面积
case 'bl': return [f.y + h, f.x + w, Math.min(rw, rh)]; // 靠左上收敛
case 'tp': return [-contactLen(cells, W, H, f.x, f.y, w, h), f.y, f.x]; // 最大贴边对齐
}
}
| 口味 | 通俗理解 | 适合的场景 |
|---|---|---|
| bssf 最贴合短边 | 尽量把窄缝填死,不浪费细长空档 | 长短条混放(1×6 与 2×2) |
| blsf 最贴合长边 | 尽量让剩余空间还是个方正的大块 | 后续还有大件要放 |
| baf 最小剩余面积 | 谁被填得最满就放谁 | 追求利用率 |
| bl 靠左上收敛 | 像真人一样从左上角一格格码 | 规则好照做、直观 |
| tp 最大贴边对齐 | 让新物品尽量贴着已有物品和箱壁 | 摆出来最整齐、不留飘空 |
contactLen 统计候选位置四条边中有多少格紧贴已占用格子或容器边框,贴得越多值越大,取负号后成为"越小越优"的第一判据。
白名单判定(容器级筛选)
白名单在候选枚举之前生效:只要容器不收这个尺寸或类型,就把整个容器从候选里划掉,连"找落点"都不做。这样既保证结果合规,也顺带减少了搜索量。
function sizeAllowed(allow, it) { // 尺寸名单
if (!allow || !allow.length) return true; // 空名单 = 不限
return allow.indexOf(it.w + 'x' + it.h) >= 0 ||
allow.indexOf(it.h + 'x' + it.w) >= 0; // 旋转 90° 等价
}
function typeAllowed(allowTypes, it) { // 类型名单
if (!allowTypes || !allowTypes.length) return true;
return allowTypes.indexOf(it.type || '藏品') >= 0; // 缺省类型按"藏品"
}
// 两张名单同时生效
if (!sizeAllowed(st.allow, it)) continue;
if (!typeAllowed(st.allowTypes, it)) continue;
5 · 七种排队顺序:buildOrders
人理包的习惯是"先放大件",但"大"有很多种定义,程序干脆全部试一遍。
function buildOrders(items) {
const by = f => items.slice().sort(f);
return [
{ label: '面积从大到小', order: by((a, b) => b.w * b.h - a.w * a.h) },
{ label: '最长边从大到小', order: by((a, b) => Math.max(b.w, b.h) - Math.max(a.w, a.h)) },
{ label: '最短边从大到小', order: by((a, b) => Math.min(b.w, b.h) - Math.min(a.w, a.h)) },
{ label: '周长从大到小', order: by((a, b) => (b.w + b.h) - (a.w + a.h)) },
{ label: '高度从大到小', order: by((a, b) => b.h - a.h || b.w - a.w) },
{ label: '宽度从大到小', order: by((a, b) => b.w - a.w || b.h - a.h) },
{ label: '占地面积小者优先', order: by((a, b) => a.w * a.h - b.w * b.h) }
];
}
6 · 重力压缩:把解规范化成人能照做的样子
数学上,"整体平移过的两个方案"是同一个解;但游戏里你不能让物品飘在半空。所以每次构造完成后,都做一轮先向上、再向左的收敛压缩:对每件物品,只要上方还有空就往上顶,顶不动了再往左推,反复扫到没有任何物品能动为止。
function compact(placed, W, H, maxPass = 60) {
const cells = new Uint8Array(W * H);
for (const p of placed) markCells(cells, W, p, 1);
for (let pass = 0; pass < maxPass; pass++) {
let moved = false;
const order = placed.slice().sort((a, b) => (a.y - b.y) || (a.x - b.x)); // 从最上面开始扫
for (const p of order) {
markCells(cells, W, p, 0); // 先把自己从位图上抹掉
while (p.y > 0 && regionFree(cells, W, H, p.x, p.y - 1, p.w, p.h)) { p.y--; moved = true; } // 向上顶到底
while (p.x > 0 && regionFree(cells, W, H, p.x - 1, p.y, p.w, p.h)) { p.x--; moved = true; } // 再向左推到底
markCells(cells, W, p, 1);
}
if (!moved) break; // 一整轮没人动 → 已收敛
}
return placed;
}
压缩带来的三个好处:① 大量等价解被归一到同一形态,评分不再被"虚高"的空隙干扰;② 剩余空间被挤到一侧,更容易连成整块,方便继续塞货(页面里的"最大空白矩形"就是这么算出来的);③ 输出给玩家的图天然是"贴着上边左边"的,好照着摆。
7 · 搜索框架:确定性穷举 + 随机重启 + 局部搜索
async function solveAsync(items, bins, opts) {
const policies = opts.ordered ? [true] : [false, true];
// 一次"完整试排 + 压缩 + 打分",只有更优才留下
const evaluate = (order, heur) => {
for (const pol of policies) {
const r = packMulti(order, bins, heur, allowRotate, pol);
compactMulti(r.states);
const score = scoreMulti(r.states, mode); // 见第 8 节
if (!best || cmpArr(score, best.score) > 0) { best = {...}; bestOrder = order; }
}
};
// 阶段 1:确定性穷举 —— 5 种落点口味 × 7 种排序 = 35 次完整试排
for (const h of HEURISTICS)
for (const o of buildOrders(active)) evaluate(o.order, h.id);
// 阶段 2:时间预算内随机重启 + 局部搜索
while (nowMs() - t0 < budgetMs) {
for (let k = 0; k < 120; k++) evaluate(mutate(), pickHeuristic());
await new Promise(r => setTimeout(r, 0)); // 分片让出主线程:界面不卡、进度条能动
}
return best;
}
7.1 四种扰动算子(mutate)
① 邻域交换:在最优顺序上随机做 1~3 次位置互换 —— 微调,往山头上爬 ② 失败者前置:把上一轮"放不下"的物品全部提到最前面 —— 改变布局结构,用小事换出大事的位置 ③ 部分打乱 :取一种经验排序,随机打乱其中约 30% 的位置 —— 中等幅度探索 ④ 完全随机 :整体 shuffle —— 跳出局部最优
// ② 的实现:失败者前置 const unIds = new Set(best.unplaced.map(u => u.id)); const front = bestOrder.filter(it => unIds.has(it.id)); // 上轮没放下的 const back = bestOrder.filter(it => !unIds.has(it.id)); // 上轮放下的 const cand = front.concat(shuffleInPlace(back, rnd)); // 先安排失败者,其余随机
7.2 时间预算的准确含义
下拉框里的"光速 0.01s / 极速 0.1s / …"是阶段 2 的预算上限,不是总耗时承诺。阶段 1 那 35 次试排是固定的保底动作,无论选哪档都会跑完。
| 档位 | 预算 | 实测总耗时 | 搜索轮次 | 结果特征 |
|---|---|---|---|---|
| 光速 0.01s | 10 ms | 12~19 ms | 约 155~515 轮 | 几乎只跑完 35 次经验试排 |
| 极速 0.1s | 100 ms | 100~110 ms | 约 2k~9k 轮 | 小规模已收敛到与标准档相同的解 |
| 快速 0.4s | 400 ms | 约 405 ms | 约 8k~9k 轮 | 绝大多数场景稳定收敛 |
| 标准 1.5s | 1500 ms | 约 1500 ms | 约 30k~170k 轮 | 默认档,极限紧凑场景更有优势 |
| 深度 / 极限 | 4 s / 10 s | 同步 | 更多 | 用于"很多小背包 + 很多物品"的困难实例 |
8 · 解的评价与比较
所有候选解都用同一个打分函数排序,再靠字典序比较逐项裁决。把所有项取负号,就把"越小越好"统一成了"越大越好"。
function cmpArr(a, b) { // 字典序:从第 1 项开始比,谁小谁优
for (let i = 0; i < Math.min(a.length, b.length); i++)
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
return a.length - b.length;
}
function scoreMulti(states, mode, ordered) {
let count = 0, area = 0, totalH = 0, weighted = 0;
const ranks = binRanks(states); // 受限 → 万能 → 主仓库
states.forEach((st, i) => {
let usedH = 0;
for (const p of st.placed) { area += p.w * p.h; usedH = Math.max(usedH, p.y + p.h); }
count += st.placed.length;
totalH += usedH; // 总占用行数
weighted += ranks[i] * usedH; // 优先级名次 × 占用行数:名次小的先装满
});
return (mode === 'area' ? [area, count] : [count, area])
.concat([-totalH, -weighted]); // 取负 = 越大越好
}
第 4 项 -weighted 就是"装填优先级"的数学表达:每个容器按 受限仓库(名次 0) → 万能仓库(名次 1) → 主仓库(名次 2) 编排名次,名次越小越先装满。于是"没设任何白名单、什么都能放"的万能仓库、以及天然万能的主仓库会被自动留到最后——同分时它们占用越少越好,这就是"给万能仓库留余量"的量化方式。
function binTier(b) { // 装填优先级:数字越小越先装
if (b.main) return 2; // 主仓库:天然万能,永远最后
const hasSize = b.allow && b.allow.length;
const hasType = b.allowTypes && b.allowTypes.length;
return (hasSize || hasType) ? 0 : 1; // 受限仓库 0 / 万能仓库 1
}
function binRanks(states) { // 每个容器的名次(同级保持传入顺序)
const rank = new Array(states.length);
states.map((st, i) => i)
.sort((a, b) => binTier(states[a]) - binTier(states[b]) || a - b)
.forEach((bi, r) => { rank[bi] = r; });
return rank;
}
这个名次同时用于三处:顺序装填时按名次遍历容器、全局最优模式下的同分并列项、以及评分函数的加权项——三处一致,才能保证"万能仓库与主仓库永远留到最后"。
9 · 统计量:利用率、占用行数与最大空白矩形
结果区那几个数字全部是现算的,不是估的。
利用率 = 已放物品面积之和 / 该容器面积 × 100% 占用行数 usedH = max(p.y + p.h) // 最下面那件物品压到第几行 剩余整块空行 = Σ(容器高 - 该容器 usedH)
最大空白矩形用直方图法求:逐行维护"这一列往上连续空了多少格"的高度数组,再用单调栈求直方图里的最大矩形。它回答的是"现在还能再塞一件多大的货"。
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) heights[x] = cells[y * W + x] ? 0 : heights[x] + 1;
// 对 heights 跑一遍单调栈 → 得到以 y 为底的最大矩形,全局取最大
}
10 · 交互层用到的同一套判定
手动微调不是"看起来差不多就行",它复用同一套位图合法性判定,所以任何时刻都不可能摆出非法局面。
10.1 拖动:把自己的格子排除后再试算
function occupancyFor(binId, excludeId) { // 该容器当前的占用位图(不含被拖的那件)
const ct = containerById(binId);
const cells = new Uint8Array(ct.width * ct.height);
const bin = state.solution.bins.find(b => b.id === binId);
for (const p of bin.placed) if (p.id !== excludeId) markCells(cells, ct.width, p, 1);
return cells;
}
// 指针移动时实时判定;无效则整块描红,松手自动回弹
const free = regionFree(cells, ct.width, ct.height, nx, ny, p.w, p.h);
跨容器拖动通过命中测试 gridAtPoint() 判断指针落在哪个网格上,命中另一个容器时直接改变物体归属并沿用同一套判定,所以"搬错箱子"同样会被拦下。
10.2 双击旋转:环形候选搜索
旋转后尺寸变成 (h, w),原地往往放不下。于是以原中心为圆心,按半径 0、1、2、3 的环形顺序寻找第一个合法落点——优先原地,实在不行就微移一两格。
for (let r = 0; r <= 3; r++)
for (const [dx, dy] of ring(r)) // 半径 r 上的一圈偏移
if (regionFree(cells, W, H, bx + dx, by + dy, p.h, p.w)) { /* 旋转成功 */ }
10.3 📐 最小尺寸试算:对容器尺寸做一次线性搜索
// 仓库模式:宽度固定 9,只扫高度 8~16(9 个候选)
// 背包模式:枚举 5 × 9 = 45 个候选,按"先窄后矮"的顺序试
for (let w = 1; w <= 5; w++)
for (let h = 1; h <= 9; h++) {
const r = await solveAsync(scope, [{ width: w, height: h }], { budgetMs: 60 });
if (r.placedAll.length === r.total) return { w, h }; // 找到第一个装得下的最小尺寸
if (nowMs() - t0 > 15000) break; // 安全阀:最多试算 15 秒
}
已有方案时,scope 是该容器当前装着的物品(问:这口箱子能压到多小?);尚未求解时,scope 是全部物品且其它容器保持不变(问:这口箱子至少要多大?)。
11 · 正确性自检与已知边界
- 每次求解后自动校验:
validateSolution()逐容器重放一遍,检查每件物品是否越界、是否重叠、binId是否与所在容器一致;不通过会在控制台报警。 - 启发式没有最优性证明:工具给出的是"时间预算内试过的最好解",不是数学意义上的最优解。0.01 秒档省掉的只是随机试探,35 次经验规则永远会跑完,因此有一个稳定的质量下限。
- 为什么启发式通常够用:本题宽度极窄(仓库 9 格、背包最多 5 格),轮廓状态少、碎片形态有限,经验规则很容易逼近最优;只有当"很多个小容器 + 很多物品"同时出现时,长预算档才会体现出可见差距。
- 不可行时的结论是可信的:"尺寸超出容器"由几何直接判定(两种朝向都放不进任何一个容器),与搜索质量无关。
12 · 结果稳定性:固定随机种子 + 确定性轮次 + 沿用上次摆放
朴素实现里,"随机重启"用 Math.random() 取随机数、用墙钟时间决定什么时候停,于是:机器快一点多跑几千轮、慢一点少跑几千轮,加上随机数每次都不同,同一批物品每次算出来的摆法就会差很多。这里做了三件事把它稳住。
1)固定种子的伪随机数
function mulberry32(seed) { // 同一个种子 → 同一串随机数
let a = seed >>> 0;
return function () {
a |= 0; 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;
};
}
const DEFAULT_SEED = 0x5F3A21C7; // 默认基准种子("换个方案"时 +1 偏移)
2)确定性的轮次上限
除了时间预算,再按"问题规模 + 精度档"算出一个轮次上限,两者谁先到就停。这样跑多少轮只取决于输入,不取决于机器快慢:
maxIters = 4000 × (60 / max(30, 物品数)) × (预算毫秒 / 1500) // 夹在 0 ~ 60000
while (iterations < maxIters && nowMs() - t0 < budgetMs) { ... }
时间预算退化为"慢机器上的硬保险":真被它截断时,结果里会带 truncated = true,界面上也会提示"本次被时间上限截断",避免你以为方案本该如此。
3)沿用上次摆放(增量稳定)
同输入必然同输出,但"加了新战利品"毕竟换了输入,全局重排仍会让整个仓库面目全非。所以再加一层:把上次已经摆好的物品钉在原坐标上,本轮只把新增/未放入的物品填进空隙。
function makeStates(bins, pinned) { // pinned = { 容器id: [{id,x,y,w,h}] }
// 1. 正常建空状态
// 2. 对每件固定物品:占格子 + 从自由矩形里挖掉它
markCells(st.cells, st.width, p, 1);
st.free = splitFree(st.free, { x: p.x, y: p.y, w: p.w, h: p.h });
st.placed.push({ ...p, pinned: true });
// 3. 后续装箱循环完全不变,只是自由矩形少了一块
}
// 每轮评价克隆一份初始状态(比重新构造省得多)
const r = packInto(cloneStates(baseStates), order, heur, allowRotate, pol);
compactMulti(r.states, true); // 重力压缩时跳过 pinned,保证不动
触发条件很克制,避免"我想算得更好却没反应":
- 工具栏「沿用上次摆放」勾选中(默认开)
- 并且问题签名没变:容器 id / 尺寸 / 两张白名单 / 优化目标 / 是否允许旋转 / 是否顺序装填,任一变化都视为新的问题 → 全局重排
- 点「🎲 换个方案」= 种子 +1 且强制全局重排;求解精度档变化也会走全局重排
这样一来,「同样的输入」两次结果逐件完全一致;「只加了几件战利品」时老物品坐标100% 保持不动,只有新物品被塞进空隙。
13 · 交互提示
- 类型物品分 藏品 / 装备 / 武器 / 药品 / 弹药 五类。添加模式下先选中一个类型标签,再点尺寸或填宽高数量,加进来的就是该类型;设置模式下同样先选类型,下面的数量即该类型各尺寸的件数,切换标签即切换统计口径(每种尺寸上限 999)。随机战利品会按权重产出多种类型。名称默认按"类型 + 尺寸"规范生成,也可自己填。
- 白名单仓库模式下,每个仓库行都有 🔒 白名单 按钮:展开后可分别勾选可接收的 类型 与 尺寸(都可多选,空选=不限,旋转等价,两张名单同时生效);标题与棋盘头部会显示 🔒 标记。手动把物品拖进不合规的仓库会被拦下。
- 稳定工具栏「沿用上次摆放」(默认开):再次求解时保留上次已摆好的物品位置,只把新增物品补进空隙;改动仓库尺寸/白名单/优化目标/精度档会自动改为全局重排。「🎲 换个方案」= 换一个随机种子全局重排一次。默认种子固定,所以同样的物品+仓库+设置每次结果完全一致。
- 拖动方案生成后可拖动方块微调;拖到另一个容器的网格上即可跨容器搬运(跨容器会自动换算两边的格子比例),红色描边表示非法,松手自动回弹(白名单不允许的位置同样会被拦下)。
- 主仓库仓库模式下可添加 1 个主仓库:尺寸固定 9×51,不可改尺寸、不能试算最小尺寸、也不能设白名单(天然万能),但可改名、删除后重新添加;棋盘区里它独占右侧一列并按自己的比例缩放,因此不会影响其它仓库"每行 3 个"的排版。背包模式下会自动隐藏(数据保留)。
- 双击方块原地旋转 90°,按环形候选搜索自动找一个放得下的位置。
- 复原点"复原为自动方案"撤销全部手动改动,回到求解器给出的原始方案。
- 📐试算某个容器的最小可行尺寸,算完直接应用。
- 顺序装填勾选=靠前的容器优先塞满;取消=两种装填策略都试一遍取更优者。
- 修改容器尺寸或物品清单会作废当前方案(需重新点「开始放置」);所有配置与主题保存在浏览器本地。