No level in this project is more than a third pieces — 13% to 28% across the shipping fifty, and the thousand-level set that preceded it peaks at 32% with exactly one level above 30%. Clearing a full board is the satisfying part of a clearing game, and it has never been on offer. It was not a design choice. deadTurnAudit walks the reachable state space with a 20000-state budget, and the endgame gate read its answer. Past about eleven pieces that budget is gone long before the walk reaches any state with three pieces left, so the audit returned `endgameMinOpen: null` — and the gate, written `endgameMinOpen >= 2`, compared null and got false. Every denser level was thrown out for a bad endgame that was never looked at. `exhausted` was right there in the audit's return value and nothing consulted it. Endgames are now enumerated outright rather than walked to: C(26,3) is 2600 subsets, so it finishes on any board this game will ever have. The statistic is the SHARE of endgames that are tight, not the minimum over them, because a minimum over more subsets falls for arithmetic alone — the same confound that made doubleDead look eight times worse for deep levels when the rate was 2% against 5%. By share, crowded boards are the better ones: worst 0.33% against 1.73% for sparse. With the gate measuring something, density inverts. At eight pieces per orientation: 44% density, 18 of 23 candidates passing the whole chain, and not one failing the dead-turn gate. At two: 19% density, 9 of 23, with eleven failing on dead turns. perTurn becomes a swept dial, and the hard piece ceiling that truncated the placement schedule regardless of it is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
11 KiB
HTML
190 lines
11 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>路线画风 · 审查台</title>
|
||
<style>
|
||
:root{
|
||
--ground:#EFF1F3; --surface:#FFF; --panel:#F6F8F9; --ink:#131C24; --ink2:#465766; --ink3:#6D7C89;
|
||
--rule:#D4DAE0; --rail:#3F5E79; --good:#226D63; --bad:#AF3A26; --warn:#895B0E;
|
||
color-scheme:light;
|
||
}
|
||
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
|
||
--ground:#0E151B; --surface:#131C24; --panel:#131C24; --ink:#E3E9EE; --ink2:#ADBCC8; --ink3:#8393A1;
|
||
--rule:#27333E; --rail:#8BAFCB; --good:#4EC6B6; --bad:#E3826B; --warn:#DFAD5D;
|
||
color-scheme:dark;
|
||
}}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--ground);color:var(--ink);
|
||
font-family:"PingFang SC","Hiragino Sans GB",ui-sans-serif,system-ui,sans-serif;line-height:1.65}
|
||
.wrap{max-width:1240px;margin:0 auto;padding:38px 20px 90px}
|
||
h1{font-size:29px;margin:0 0 10px;letter-spacing:-.02em}
|
||
.lede{color:var(--ink2);font-size:15px;max-width:74ch;margin:0 0 6px}
|
||
.meta{font-size:12.5px;color:var(--ink3);font-family:ui-monospace,monospace;margin:0 0 24px}
|
||
h2{font-size:19px;margin:38px 0 4px;padding-bottom:7px;border-bottom:1px solid var(--rule)}
|
||
.hint{font-size:13px;color:var(--ink3);margin:0 0 14px}
|
||
.bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;background:var(--surface);
|
||
border:1px solid var(--rule);border-radius:12px;padding:12px 14px;margin:0 0 20px;position:sticky;top:0;z-index:5}
|
||
.bar label{font-size:12.5px;color:var(--ink2);display:flex;gap:6px;align-items:center}
|
||
select,button{font:inherit;font-size:13px;padding:6px 11px;border-radius:8px;border:1px solid var(--rule);
|
||
background:var(--panel);color:var(--ink);cursor:pointer}
|
||
button.on{background:var(--rail);color:#fff;border-color:var(--rail)}
|
||
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(228px,1fr))}
|
||
.card{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:11px;display:flex;flex-direction:column;gap:7px}
|
||
.card h3{margin:0;font-size:13px;display:flex;align-items:baseline;gap:7px}
|
||
.card h3 b{font-family:ui-monospace,monospace;color:var(--rail);font-size:14px}
|
||
.card h3 .fam{margin-left:auto;font-size:11px;color:var(--ink3)}
|
||
.board{width:100%;aspect-ratio:1;background:var(--panel);border-radius:9px;display:block}
|
||
.kv{display:grid;grid-template-columns:auto auto;gap:0 8px;font-size:11.5px;color:var(--ink2)}
|
||
.kv b{font-family:ui-monospace,monospace;color:var(--ink);text-align:right}
|
||
.kv b.hot{color:var(--bad)} .kv b.ok{color:var(--good)}
|
||
.note{background:var(--panel);border:1px solid var(--rule);border-left:3px solid var(--rail);
|
||
border-radius:10px;padding:2px 16px;margin:14px 0}
|
||
.note p{font-size:13.5px;color:var(--ink2);max-width:78ch}
|
||
.note b{color:var(--ink)}
|
||
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:10px 0 6px}
|
||
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--rule)}
|
||
th{font-size:11px;letter-spacing:.06em;color:var(--ink3);text-transform:uppercase;font-weight:600}
|
||
td.n{text-align:right;font-family:ui-monospace,monospace}
|
||
.good{color:var(--good);font-weight:600}.bad{color:var(--bad);font-weight:600}
|
||
footer{margin-top:46px;padding-top:16px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<h1>路线画风 · 审查台</h1>
|
||
<p class="lede">这里画的是<b>轨道本身</b>,不是关卡。先看线好不好看,再谈难度——因为线是玩家一眼就会看到的东西。</p>
|
||
<p class="meta" id="meta">载入中…</p>
|
||
|
||
<div class="note">
|
||
<p><b>「直穿填充」是那些满屏直线的来源。</b>每个家族只铺自己的签名路线,盘面剩下的端口由填充逻辑配对,而它<b>默认配成对穿的直线</b>。一个格子里两条直线必定相交,所以直线多 = 交叉点多,两件事是同一个原因。</p>
|
||
<p><b>它还在压制反差。</b>换成偏好弯道之后,花瓣的反差从 4.2 升到 14.0、双叶跨区从 4.8 升到 28.8、环绕从 10.6 升到 22.9 —— 前两个家族原本是被「反差 ≥12」这道闸门挡在关卡池之外的,其中花瓣正是「像花一样」的那个。<br><span style="font-size:12.5px;color:var(--ink3)">(反差要在盘面上摆好棋子、长成关卡之后才测得出来,所以不在下面这张表里;这几个数字来自六个种子的构造结果。)</span></p>
|
||
</div>
|
||
|
||
<div class="bar">
|
||
<label>填充方式
|
||
<span><button data-fill="straight">直穿(原来)</button><button data-fill="curve" class="on">弯道(候选)</button></span>
|
||
</label>
|
||
<label>底板 <select id="radius"><option value="3">37 格</option><option value="4" selected>61 格</option><option value="5">91 格</option></select></label>
|
||
<label>每族张数 <select id="count"><option>3</option><option selected>4</option><option>6</option></select></label>
|
||
</div>
|
||
|
||
<h2>六个家族的画风</h2>
|
||
<p class="hint">同一个种子、同一个底板,只换填充方式。数字越低越好:<b>直线占比</b>是对穿轨道占全部轨道的比例,<b>交叉/格</b>是每个格子里互相穿过的轨道对数。</p>
|
||
<table id="fam-table"><thead><tr><th>家族</th><th class="n">直线占比</th><th class="n">交叉/格</th><th>说明</th></tr></thead><tbody></tbody></table>
|
||
<div class="grid" id="families"></div>
|
||
|
||
<h2>当前交付的 50 关</h2>
|
||
<p class="hint">这是现在能玩到的那 50 关的盘面,用同一套指标标注。标红的是直线占比超过 60% 的。</p>
|
||
<div class="grid" id="campaign"></div>
|
||
|
||
<footer>
|
||
<p class="mono">labs/route-gallery · 轨道由 src/renderer.js 实际绘制,和游戏里看到的是同一套画法</p>
|
||
</footer>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import { createRouteBoard, ROUTE_FAMILIES } from '../../src/route-grammar.js';
|
||
import { drawScene, CanvasPainter } from '../../src/renderer.js';
|
||
import { layout } from '../../src/geometry.js';
|
||
|
||
const $ = id => document.getElementById(id);
|
||
let FILL = 'curve';
|
||
|
||
// A leftover port paired with the one straight across is a diameter; two
|
||
// diameters in one cell always cross. Both numbers come from the same data.
|
||
const pairsOf = tracks => {
|
||
const seen = new Set(), out = [];
|
||
for (let i = 0; i < 6; i++) { if (seen.has(i)) continue; const j = tracks[i]; seen.add(i); seen.add(j); out.push([i, j]); }
|
||
return out;
|
||
};
|
||
const isStraight = ([a, b]) => (a + 3) % 6 === b;
|
||
const between = (x, p, q) => { for (let k = (p + 1) % 6; k !== q; k = (k + 1) % 6) if (k === x) return true; return false; };
|
||
const crosses = ([a, b], [c, d]) => between(c, a, b) !== between(d, a, b);
|
||
function shape(level) {
|
||
let pairs = 0, straight = 0, cross = 0;
|
||
for (const cell of level.cells) {
|
||
const P = pairsOf(cell.tracks);
|
||
pairs += P.length; straight += P.filter(isStraight).length;
|
||
for (let i = 0; i < P.length; i++) for (let j = i + 1; j < P.length; j++) if (crosses(P[i], P[j])) cross++;
|
||
}
|
||
return { straight: straight / pairs, cross: cross / level.cells.length };
|
||
}
|
||
|
||
function paint(canvas, level) {
|
||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||
const box = canvas.getBoundingClientRect();
|
||
const w = Math.max(180, box.width), h = w;
|
||
canvas.width = w * dpr; canvas.height = h * dpr;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, w, h);
|
||
drawScene(new CanvasPainter(ctx), level, level.tiles ?? [], layout(level.cells, w, h), {});
|
||
}
|
||
|
||
const card = (title, fam, level, extra = '') => {
|
||
const s = shape(level);
|
||
return `<div class="card">
|
||
<h3><b>${title}</b><span class="fam">${fam}</span></h3>
|
||
<canvas class="board"></canvas>
|
||
<div class="kv">
|
||
<span>直线占比</span><b class="${s.straight > .6 ? 'hot' : s.straight < .35 ? 'ok' : ''}">${Math.round(s.straight * 100)}%</b>
|
||
<span>交叉 / 格</span><b class="${s.cross > 2 ? 'hot' : s.cross < 1.2 ? 'ok' : ''}">${s.cross.toFixed(2)}</b>
|
||
${extra}
|
||
</div>
|
||
</div>`;
|
||
};
|
||
|
||
function renderFamilies() {
|
||
const radius = Number($('radius').value), count = Number($('count').value);
|
||
const cards = [], rows = [];
|
||
for (const { id, name, description } of ROUTE_FAMILIES) {
|
||
const boards = [];
|
||
for (let seed = 0; seed < count; seed++) boards.push(createRouteBoard(seed, { family: id, radius, fill: FILL }));
|
||
const s = boards.map(shape);
|
||
const avg = k => s.reduce((n, x) => n + x[k], 0) / s.length;
|
||
rows.push(`<tr><td><b>${name}</b> <span style="color:var(--ink3)">${id}</span></td>
|
||
<td class="n ${avg('straight') > .6 ? 'bad' : avg('straight') < .35 ? 'good' : ''}">${Math.round(avg('straight') * 100)}%</td>
|
||
<td class="n ${avg('cross') > 2 ? 'bad' : avg('cross') < 1.2 ? 'good' : ''}">${avg('cross').toFixed(2)}</td>
|
||
<td style="font-size:12.5px;color:var(--ink2)">${description}</td></tr>`);
|
||
boards.forEach((b, i) => cards.push({ html: card(`${name} #${i}`, id, b), level: b }));
|
||
}
|
||
$('fam-table').querySelector('tbody').innerHTML = rows.join('');
|
||
$('families').innerHTML = cards.map(c => c.html).join('');
|
||
[...$('families').querySelectorAll('canvas')].forEach((cv, i) => paint(cv, cards[i].level));
|
||
}
|
||
|
||
async function renderCampaign() {
|
||
try {
|
||
const res = await fetch('../../levels/campaign-50.json?t=' + Date.now());
|
||
const levels = (await res.json()).levels;
|
||
$('campaign').innerHTML = levels.map((l, i) =>
|
||
card(String(i + 1).padStart(2, '0'), l.campaign?.family ?? '', l,
|
||
`<span>棋子密度</span><b class="${l.tiles.length / l.cells.length >= .35 ? 'ok' : l.tiles.length / l.cells.length < .22 ? 'hot' : ''}">${l.tiles.length} / ${l.cells.length} = ${Math.round(100 * l.tiles.length / l.cells.length)}%</b>`)).join('');
|
||
[...$('campaign').querySelectorAll('canvas')].forEach((cv, i) => paint(cv, levels[i]));
|
||
const s = levels.map(shape);
|
||
const avg = k => s.reduce((n, x) => n + x[k], 0) / s.length;
|
||
const dens = levels.map(l => l.tiles.length / l.cells.length);
|
||
const mean = a => a.reduce((n, x) => n + x, 0) / a.length;
|
||
$('meta').textContent = `当前 50 关:直线占比均值 ${Math.round(avg('straight') * 100)}%(超过 60% 的 ${s.filter(x => x.straight > .6).length} 关)`
|
||
+ ` · 交叉每格 ${avg('cross').toFixed(2)}`
|
||
+ ` · 棋子密度均值 ${Math.round(mean(dens) * 100)}%,最高 ${Math.round(Math.max(...dens) * 100)}%,35% 以上 ${dens.filter(x => x >= .35).length} 关`;
|
||
} catch (e) {
|
||
$('campaign').innerHTML = `<p class="hint">读不到 levels/campaign-50.json:${e.message}</p>`;
|
||
}
|
||
}
|
||
|
||
for (const b of document.querySelectorAll('[data-fill]')) b.onclick = () => {
|
||
FILL = b.dataset.fill;
|
||
document.querySelectorAll('[data-fill]').forEach(x => x.classList.toggle('on', x === b));
|
||
renderFamilies();
|
||
};
|
||
$('radius').onchange = renderFamilies;
|
||
$('count').onchange = renderFamilies;
|
||
renderFamilies();
|
||
renderCampaign();
|
||
</script>
|
||
</body>
|
||
</html>
|