feat(levels): assemble the 50-level campaign and the sheet to review it
The deep-lock construction cannot go below three turns, so it cannot supply an opening band — every level it makes lands mid-difficulty and the curve comes out flat. harvest-easy pulls the shallow end from the shipped v0.5 set, but only the 3% of it that holds up: 568 of 1000 need no rotation at all and 51 more are clearable by tapping at random, which is what the monkey threshold is for. 33 levels survive, at one and two turns. select-50 scores candidates on mechanical features only, spreads the pick evenly across that range, and caps any one route grammar at 36% so the campaign cannot quietly become one template repeated — the shipped 1000 turned out to be thirty. Weights are a flag, so refitting them from a round of agent ratings is a re-run rather than an edit. The result is a curve rather than a plateau: difficulty score 3.85 / 6.24 / 7.60 across the three bands, rotations 1.8 / 3.0 / 3.5, and every level keeps three or more opening choices with a show of at least a second. The 50 register as a playable campaign edition, so review happens in the real game; docs/review-50.html is the handover sheet, one card per level with the opening position drawn from the solver rather than sketched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
+86548
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build docs/review-50.html — the sheet the 50 levels are handed over on.
|
||||||
|
//
|
||||||
|
// node scripts/build-review-50.mjs [--agent="R1 · grok xhigh"]
|
||||||
|
//
|
||||||
|
// One card per level: the board as it opens, what the construction guarantees,
|
||||||
|
// and what the agent that played it reported. Every card links into the real
|
||||||
|
// game at that level, because a level is judged by playing it.
|
||||||
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { trace, rotateBoard } from '../src/core.js';
|
||||||
|
import { layout } from '../src/geometry.js';
|
||||||
|
import { metrics } from '../src/bench.js';
|
||||||
|
|
||||||
|
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
|
||||||
|
const AGENT = args.agent ?? 'R1 · grok xhigh';
|
||||||
|
const OUT = resolve(args.out ?? 'docs/review-50.html');
|
||||||
|
|
||||||
|
const raw = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8'));
|
||||||
|
const levels = raw.levels;
|
||||||
|
|
||||||
|
// Agent verdicts, keyed by level id.
|
||||||
|
const verdicts = {};
|
||||||
|
const RUNS = resolve('bench/runs');
|
||||||
|
if (existsSync(RUNS)) {
|
||||||
|
for (const f of readdirSync(RUNS)) {
|
||||||
|
if (!f.endsWith('.json') || f === 'index.json') continue;
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(readFileSync(resolve(RUNS, f), 'utf8'));
|
||||||
|
if (d.agent !== AGENT || !d.level?.id) continue;
|
||||||
|
verdicts[d.level.id] = metrics(d);
|
||||||
|
} catch { /* torn read */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
|
||||||
|
const f1 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 10) / 10).toFixed(1);
|
||||||
|
|
||||||
|
// A flat SVG of the opening position: tracks in grey, pieces as arrows. Small
|
||||||
|
// enough to read fifty of them on one page.
|
||||||
|
const ARROWS = ['↑', '↗', '↘', '↓', '↙', '↖'];
|
||||||
|
function thumbnail(level, size = 168) {
|
||||||
|
const view = layout(level.cells, size, size);
|
||||||
|
const { size: r, ox, oy } = view;
|
||||||
|
const px = (q, rr) => ({ x: ox + r * 1.5 * q, y: oy + r * Math.sqrt(3) * (rr + q / 2) });
|
||||||
|
const cells = level.cells.map(c => {
|
||||||
|
const { x, y } = px(c.q, c.r);
|
||||||
|
const pts = Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const a = Math.PI / 180 * (60 * i);
|
||||||
|
return `${(x + r * Math.cos(a)).toFixed(1)},${(y + r * Math.sin(a)).toFixed(1)}`;
|
||||||
|
}).join(' ');
|
||||||
|
return `<polygon points="${pts}"/>`;
|
||||||
|
}).join('');
|
||||||
|
const open = new Set(level.tiles.filter(t => trace(level, t, level.tiles).status === 'exit').map(t => t.id));
|
||||||
|
const pieces = level.tiles.map(t => {
|
||||||
|
const { x, y } = px(t.q, t.r);
|
||||||
|
return `<g class="${open.has(t.id) ? 'free' : 'held'}"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(r * .62).toFixed(1)}"/>`
|
||||||
|
+ `<text x="${x.toFixed(1)}" y="${(y + r * .26).toFixed(1)}" font-size="${(r * .9).toFixed(1)}">${ARROWS[t.dir]}</text></g>`;
|
||||||
|
}).join('');
|
||||||
|
return `<svg viewBox="0 0 ${size} ${size}" class="thumb" role="img" aria-label="${esc(level.id)} 开局盘面">`
|
||||||
|
+ `<g class="cells">${cells}</g><g class="pieces">${pieces}</g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rated = levels.map(l => verdicts[l.id]).filter(Boolean);
|
||||||
|
const band = (a, b) => levels.slice(a, b).map(l => verdicts[l.id]).filter(Boolean);
|
||||||
|
const bandRow = (label, a, b) => {
|
||||||
|
const rows = band(a, b);
|
||||||
|
const g = k => mean(rows.map(m => Number(m.rating?.[k])).filter(x => !Number.isNaN(x)));
|
||||||
|
return `<tr><td>${label}</td><td class="n">${rows.length}</td><td class="n">${f1(g('difficulty'))}</td><td class="n">${f1(g('stuck'))}</td><td class="n">${f1(mean(rows.map(m => m.blindRotations)))}</td><td class="n">${f1(mean(rows.map(m => m.deadTaps)))}</td></tr>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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:#EEF1F4; --surface:#FFF; --inset:#E3E8ED; --panel:#F7F9FA;
|
||||||
|
--ink:#141E28; --ink2:#48596A; --ink3:#6B7A88;
|
||||||
|
--rule:#D3DAE1; --rail:#435F78; --good:#236E64; --bad:#B23A26; --warn:#8A5C0F;
|
||||||
|
--cell:#E7E1D3; --cell-line:#CFC7B4; --held:#9AA9B6; --free:#2A7F74;
|
||||||
|
color-scheme:light;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
|
||||||
|
--ground:#0F161C; --surface:#141D25; --inset:#0A1015; --panel:#141D25;
|
||||||
|
--ink:#E4EAEF; --ink2:#AEBDC9; --ink3:#8494A2;
|
||||||
|
--rule:#28343F; --rail:#8CB0CC; --good:#4FC7B7; --bad:#E4836C; --warn:#E0AE5E;
|
||||||
|
--cell:#232D36; --cell-line:#31404C; --held:#5B6D7C; --free:#4FC7B7;
|
||||||
|
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,-apple-system,sans-serif;line-height:1.65}
|
||||||
|
.wrap{max-width:1180px;margin:0 auto;padding:40px 22px 90px}
|
||||||
|
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
|
||||||
|
h1{font-size:30px;margin:0 0 10px;letter-spacing:-.02em}
|
||||||
|
.lede{color:var(--ink2);font-size:15px;max-width:70ch;margin:0 0 8px}
|
||||||
|
.meta{font-size:12.5px;color:var(--ink3);margin:0 0 26px}
|
||||||
|
h2{font-size:19px;margin:40px 0 10px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:12px 0 26px}
|
||||||
|
th,td{text-align:left;padding:8px 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,th.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap}
|
||||||
|
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(212px,1fr))}
|
||||||
|
.card{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:12px;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.card h3{margin:0;font-size:13.5px;display:flex;align-items:baseline;gap:7px}
|
||||||
|
.card h3 .no{font-family:ui-monospace,monospace;font-size:15px;font-weight:700;color:var(--rail)}
|
||||||
|
.card h3 .fam{font-size:11px;color:var(--ink3);margin-left:auto}
|
||||||
|
.thumb{width:100%;height:auto;background:var(--inset);border-radius:8px;display:block}
|
||||||
|
.thumb .cells polygon{fill:var(--cell);stroke:var(--cell-line);stroke-width:.6}
|
||||||
|
.thumb .pieces circle{fill:var(--held)}
|
||||||
|
.thumb .pieces .free circle{fill:var(--free)}
|
||||||
|
.thumb text{fill:#fff;text-anchor:middle;font-family:system-ui}
|
||||||
|
.kv{display:grid;grid-template-columns:auto auto;gap:1px 8px;font-size:11.5px;color:var(--ink2)}
|
||||||
|
.kv b{font-family:ui-monospace,monospace;color:var(--ink);font-weight:600;text-align:right}
|
||||||
|
.verdict{font-size:11.5px;border-top:1px dashed var(--rule);padding-top:7px;color:var(--ink2)}
|
||||||
|
.verdict .row{display:flex;gap:9px;flex-wrap:wrap;font-family:ui-monospace,monospace}
|
||||||
|
.verdict .worst{margin-top:4px;color:var(--ink3);line-height:1.5}
|
||||||
|
.play{display:block;text-align:center;background:var(--rail);color:#fff;text-decoration:none;border-radius:8px;padding:7px;font-size:12.5px;font-weight:600;margin-top:auto}
|
||||||
|
.pill{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:20px;font-weight:600}
|
||||||
|
.pill.bad{background:var(--bad);color:#fff}
|
||||||
|
.pill.warn{background:var(--warn);color:#fff}
|
||||||
|
footer{margin-top:50px;padding-top:18px;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 mono">关卡集 levels/campaign-50.json · agent 实测批次 ${esc(AGENT)}(${rated.length}/${levels.length} 关已评)· 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
|
||||||
|
|
||||||
|
<h2>难度曲线(agent 实测)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>档</th><th class="n">已评关数</th><th class="n">难度</th><th class="n">无解感</th><th class="n">盲转/关</th><th class="n">空点/关</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${bandRow('前档 L1–L12', 0, 12)}
|
||||||
|
${bandRow('中档 L13–L32', 12, 32)}
|
||||||
|
${bandRow('后档 L33–L50', 32, 50)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="meta">难度 3 = 刚刚好;无解感越低越好。这两项是评分里唯二复测可信的(r = 0.93 / 0.85),其余几项是模型噪声,不列。</p>
|
||||||
|
|
||||||
|
<h2>逐关</h2>
|
||||||
|
<div class="grid">
|
||||||
|
${levels.map((l, i) => {
|
||||||
|
const c = l.campaign ?? {};
|
||||||
|
const v = verdicts[l.id];
|
||||||
|
const flags = [];
|
||||||
|
if (v && !v.cleared) flags.push('<span class="pill bad">未通关</span>');
|
||||||
|
if (v && Number(v.rating?.stuck) >= 4) flags.push('<span class="pill warn">觉得无解</span>');
|
||||||
|
if (v && v.blindRotations >= 2) flags.push('<span class="pill warn">盲转多</span>');
|
||||||
|
return `<div class="card">
|
||||||
|
<h3><span class="no">${String(i + 1).padStart(2, '0')}</span>${flags.join('')}<span class="fam">${esc(c.family ?? '')}</span></h3>
|
||||||
|
${thumbnail(l)}
|
||||||
|
<div class="kv">
|
||||||
|
<span>旋转 / 棋子</span><b>${c.rotations ?? '?'} 转 · ${c.pieces ?? l.tiles.length} 枚</b>
|
||||||
|
<span>开局可点</span><b>${c.openingChoices ?? '?'} 枚</b>
|
||||||
|
<span>开局演出</span><b>${c.openingBestMs != null ? (c.openingBestMs / 1000).toFixed(1) + 's' : '?'}</b>
|
||||||
|
<span>长巡游 / 反差</span><b>${c.cruises ?? '?'} · ${c.contrast ?? '?'}</b>
|
||||||
|
</div>
|
||||||
|
${v ? `<div class="verdict">
|
||||||
|
<div class="row"><span>难度 ${v.rating?.difficulty ?? '—'}</span><span>无解感 ${v.rating?.stuck ?? '—'}</span><span>${v.steps} 步</span><span>${v.decisions} 决策</span></div>
|
||||||
|
${v.rating?.worst ? `<div class="worst">${esc(String(v.rating.worst).slice(0, 90))}</div>` : ''}
|
||||||
|
</div>` : '<div class="verdict">尚未实测</div>'}
|
||||||
|
<a class="play" href="../index.html?campaign=c50&level=${i + 1}">试玩 →</a>
|
||||||
|
</div>`;
|
||||||
|
}).join('\n')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p class="mono">生成:node scripts/build-review-50.mjs · 选关:node scripts/select-50.mjs · 看板:docs/level-board.html</p>
|
||||||
|
<p>缩略图与「开局可点」同源,都取自求解器对开局盘面的判定,不是示意图。</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
writeFileSync(OUT, html);
|
||||||
|
console.log(`审核页已生成:${OUT}(${levels.length} 关,${rated.length} 关已有 agent 实测)`);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Harvest the shallow end of the campaign from the shipped v0.5 set.
|
||||||
|
//
|
||||||
|
// node scripts/harvest-easy.mjs [--set=levels/v0.5.json] [--out=bench/pool-easy.json]
|
||||||
|
//
|
||||||
|
// The deep-lock construction always needs three turns, so it cannot supply the
|
||||||
|
// opening dozen levels. v0.5 can — but only the small fraction of it that holds
|
||||||
|
// up: most of that set is clearable by tapping at random, which is the failure
|
||||||
|
// the monkey threshold exists to catch.
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { Game, random, solveRotations } from '../src/core.js';
|
||||||
|
import { showStats, contrastStats } from '../src/deep-lock.js';
|
||||||
|
|
||||||
|
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
|
||||||
|
const SET = resolve(args.set ?? 'levels/v0.5.json');
|
||||||
|
const OUT = resolve(args.out ?? 'bench/pool-easy.json');
|
||||||
|
const MIN_APT = Number(args.minAPT ?? 3), MIN_CONTRAST = Number(args.minContrast ?? 10);
|
||||||
|
const MIN_OPENING_SHOW = Number(args.minOpeningShow ?? 900), MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3);
|
||||||
|
const MIN_CRUISES = Number(args.minCruises ?? 3);
|
||||||
|
|
||||||
|
function monkeyActionsPerPiece(level, runs = 4) {
|
||||||
|
let total = 0;
|
||||||
|
for (let run = 0; run < runs; run++) {
|
||||||
|
const rng = random(4242 + run * 31), game = new Game(level);
|
||||||
|
let actions = 0, streak = 0;
|
||||||
|
while (!game.won && !game.lost && actions < 800) {
|
||||||
|
if (streak >= 3) { game.rotate(rng() < .5 ? 1 : -1); actions++; streak = 0; continue; }
|
||||||
|
const tile = game.tiles[Math.floor(rng() * game.tiles.length)];
|
||||||
|
actions++;
|
||||||
|
if (game.tap(tile.id).status === 'exit') streak = 0; else streak++;
|
||||||
|
}
|
||||||
|
total += game.won ? actions / level.tiles.length : 999;
|
||||||
|
}
|
||||||
|
return total / runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = JSON.parse(readFileSync(SET, 'utf8'));
|
||||||
|
const levels = Array.isArray(raw) ? raw : raw.levels;
|
||||||
|
const kept = [];
|
||||||
|
const rejected = { unsolvable: 0, zeroRotation: 0, show: 0, contrast: 0, monkey: 0 };
|
||||||
|
for (const level of levels) {
|
||||||
|
const solution = solveRotations(level);
|
||||||
|
if (!solution.solvable) { rejected.unsolvable++; continue; }
|
||||||
|
if (solution.rotations < 1) { rejected.zeroRotation++; continue; }
|
||||||
|
const show = showStats(level);
|
||||||
|
if (!show || show.openingChoices < MIN_OPENING_CHOICES || show.openingBestMs < MIN_OPENING_SHOW || show.cruises < MIN_CRUISES) { rejected.show++; continue; }
|
||||||
|
const contrast = contrastStats(level);
|
||||||
|
if (contrast.contrast < MIN_CONTRAST) { rejected.contrast++; continue; }
|
||||||
|
const apt = monkeyActionsPerPiece(level);
|
||||||
|
if (apt < MIN_APT) { rejected.monkey++; continue; }
|
||||||
|
const copy = JSON.parse(JSON.stringify(level));
|
||||||
|
copy.prototype = {
|
||||||
|
family: 'v05', radius: null, source: 'v0.5',
|
||||||
|
stats: { rotations: solution.rotations, pieces: level.tiles.length, lockedShare: 0, minRelease: 1 },
|
||||||
|
contrast, show, monkeyActionsPerPiece: apt,
|
||||||
|
};
|
||||||
|
kept.push(copy);
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(OUT), { recursive: true });
|
||||||
|
writeFileSync(OUT, JSON.stringify({ generated: 'harvest-easy', source: SET, count: kept.length, levels: kept }, null, 1));
|
||||||
|
console.log(`采集 ${kept.length} / ${levels.length} 关 → ${OUT}`);
|
||||||
|
console.log(` 刷掉:0 转 ${rejected.zeroRotation} · 演出 ${rejected.show} · 反差 ${rejected.contrast} · 乱点即过 ${rejected.monkey}`);
|
||||||
|
console.log(` 旋转分布 ${JSON.stringify(kept.reduce((m, l) => (m[l.prototype.stats.rotations] = (m[l.prototype.stats.rotations] || 0) + 1, m), {}))}`);
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Pick the shipping 50 out of the candidate pool and order them into a curve.
|
||||||
|
//
|
||||||
|
// node scripts/select-50.mjs [--pool=bench/pool.json] [--out=levels/campaign-50.json]
|
||||||
|
// [--count=50] [--familyCap=0.36] [--weights=…]
|
||||||
|
//
|
||||||
|
// Difficulty is scored from mechanical features only. Which features and how
|
||||||
|
// much each counts is meant to be refitted from agent ratings once a round has
|
||||||
|
// been played — --weights takes the refit, so a round is one flag, not an edit.
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { deepLevelKey, showStats } from '../src/deep-lock.js';
|
||||||
|
|
||||||
|
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
|
||||||
|
const OUT = resolve(args.out ?? 'levels/campaign-50.json');
|
||||||
|
const COUNT = Number(args.count ?? 50);
|
||||||
|
const FAMILY_CAP = Number(args.familyCap ?? 0.36);
|
||||||
|
|
||||||
|
// Starting weights, replaced by the fit after a round with agents.
|
||||||
|
const DEFAULT_WEIGHTS = { rotations: 1.0, pieces: 0.10, lockedShare: 1.6, monkeyAPT: 0.14, cells: 0.004 };
|
||||||
|
const WEIGHTS = args.weights ? { ...DEFAULT_WEIGHTS, ...JSON.parse(args.weights) } : DEFAULT_WEIGHTS;
|
||||||
|
|
||||||
|
// Several pools feed one campaign: the deep-lock construction cannot go below
|
||||||
|
// three turns, so the opening band comes from elsewhere.
|
||||||
|
const pool = String(args.pool ?? 'bench/pool.json,bench/pool-easy.json').split(',')
|
||||||
|
.flatMap(path => {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(resolve(path.trim()), 'utf8'));
|
||||||
|
return Array.isArray(raw) ? raw : raw.levels;
|
||||||
|
} catch { return []; }
|
||||||
|
});
|
||||||
|
const family = id => id.startsWith('trail-') ? 'v05' : id.replace('proto-d5-', '').replace(/-r\d+-s\d+-d\d+$/, '');
|
||||||
|
|
||||||
|
const scored = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const level of pool) {
|
||||||
|
const key = deepLevelKey(level);
|
||||||
|
if (seen.has(key)) continue; // identical board + piece layout
|
||||||
|
seen.add(key);
|
||||||
|
const p = level.prototype ?? {};
|
||||||
|
const s = p.stats ?? {};
|
||||||
|
const show = p.show ?? showStats(level);
|
||||||
|
const score = WEIGHTS.rotations * (s.rotations ?? 3)
|
||||||
|
+ WEIGHTS.pieces * (s.pieces ?? level.tiles.length)
|
||||||
|
+ WEIGHTS.lockedShare * (s.lockedShare ?? 0)
|
||||||
|
+ WEIGHTS.monkeyAPT * (p.monkeyActionsPerPiece ?? 0)
|
||||||
|
+ WEIGHTS.cells * level.cells.length;
|
||||||
|
scored.push({ level, score, family: family(level.id), show, stats: s, contrast: p.contrast?.contrast ?? 0 });
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
|
// Even spread across the difficulty range, then a family cap so one route
|
||||||
|
// grammar cannot supply half the campaign — the shipped 1000 levels turned out
|
||||||
|
// to be thirty templates, which is the failure this guards against.
|
||||||
|
const cap = Math.ceil(COUNT * FAMILY_CAP);
|
||||||
|
const used = {};
|
||||||
|
const picked = [];
|
||||||
|
const takeable = i => {
|
||||||
|
const row = scored[i];
|
||||||
|
if (!row || row.taken) return false;
|
||||||
|
return (used[row.family] ?? 0) < cap;
|
||||||
|
};
|
||||||
|
for (let slot = 0; slot < COUNT; slot++) {
|
||||||
|
const ideal = Math.round(slot * (scored.length - 1) / Math.max(1, COUNT - 1));
|
||||||
|
let choice = null;
|
||||||
|
for (let radius = 0; radius < scored.length; radius++) {
|
||||||
|
for (const i of [ideal - radius, ideal + radius]) {
|
||||||
|
if (i < 0 || i >= scored.length) continue;
|
||||||
|
if (takeable(i)) { choice = i; break; }
|
||||||
|
}
|
||||||
|
if (choice != null) break;
|
||||||
|
}
|
||||||
|
if (choice == null) break;
|
||||||
|
scored[choice].taken = true;
|
||||||
|
used[scored[choice].family] = (used[scored[choice].family] ?? 0) + 1;
|
||||||
|
picked.push(scored[choice]);
|
||||||
|
}
|
||||||
|
picked.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
|
const levels = picked.map((row, i) => {
|
||||||
|
const level = JSON.parse(JSON.stringify(row.level));
|
||||||
|
level.campaign = {
|
||||||
|
index: i + 1, score: Number(row.score.toFixed(3)), family: row.family,
|
||||||
|
rotations: row.stats.rotations, pieces: row.stats.pieces,
|
||||||
|
lockedShare: row.stats.lockedShare, contrast: Number(row.contrast.toFixed(1)),
|
||||||
|
openingChoices: row.show?.openingChoices ?? null,
|
||||||
|
openingBestMs: row.show?.openingBestMs ?? null,
|
||||||
|
cruises: row.show?.cruises ?? null,
|
||||||
|
};
|
||||||
|
level.id = `c50-${String(i + 1).padStart(2, '0')}-${row.family}`;
|
||||||
|
return level;
|
||||||
|
});
|
||||||
|
|
||||||
|
mkdirSync(dirname(OUT), { recursive: true });
|
||||||
|
writeFileSync(OUT, JSON.stringify({ generated: 'select-50', version: 1, weights: WEIGHTS, count: levels.length, levels }, null, 1));
|
||||||
|
|
||||||
|
// Also emit the campaign module the game itself loads, so review happens in the
|
||||||
|
// real game rather than in a viewer written for the occasion.
|
||||||
|
const chapterSize = 10;
|
||||||
|
const chapters = Array.from({ length: Math.ceil(levels.length / chapterSize) },
|
||||||
|
(_, i) => ['起步', '转起来', '看清路', '深一点', '收束'][i] ?? `第 ${i + 1} 章`);
|
||||||
|
writeFileSync(resolve('src/levels-c50.js'),
|
||||||
|
`// Generated by scripts/select-50.mjs — do not edit by hand.\n`
|
||||||
|
+ `export const C50_CAMPAIGN = ${JSON.stringify({ id: 'c50', name: '五十关调优版', description: `从 ${pool.length} 个候选里按演出、反差与旋转深度选出的 50 关,难度递增。`, chapters, chapterSize }, null, 1)};\n`
|
||||||
|
+ `export const C50_LEVELS = ${JSON.stringify(levels)};\n`);
|
||||||
|
|
||||||
|
const band = (a, b) => picked.slice(a, b);
|
||||||
|
const avg = (rows, f) => rows.reduce((s, r) => s + f(r), 0) / Math.max(1, rows.length);
|
||||||
|
console.log(`已选 ${levels.length} 关 → ${OUT}`);
|
||||||
|
console.log(`家族分布 ${JSON.stringify(used)}(单家族上限 ${cap})`);
|
||||||
|
console.log(`旋转深度 ${JSON.stringify(picked.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('| 档 | 关号 | 难度分 | 旋转 | 棋子 | 锁住% | 反差 | 开局可点 | 开局演出 |');
|
||||||
|
console.log('|---|---|---:|---:|---:|---:|---:|---:|---:|');
|
||||||
|
for (const [label, a, b] of [['前档', 0, 12], ['中档', 12, 32], ['后档', 32, levels.length]]) {
|
||||||
|
const rows = band(a, b);
|
||||||
|
if (!rows.length) continue;
|
||||||
|
console.log(`| ${label} | L${a + 1}–L${b} | ${avg(rows, r => r.score).toFixed(2)} | ${avg(rows, r => r.stats.rotations).toFixed(1)} | ${avg(rows, r => r.stats.pieces).toFixed(1)} | ${(avg(rows, r => r.stats.lockedShare) * 100).toFixed(0)}% | ${avg(rows, r => r.contrast).toFixed(1)} | ${avg(rows, r => r.show?.openingChoices ?? 0).toFixed(1)} | ${(avg(rows, r => r.show?.openingBestMs ?? 0) / 1000).toFixed(2)}s |`);
|
||||||
|
}
|
||||||
+41
-17
@@ -1,7 +1,6 @@
|
|||||||
import { artDirection, directionTheme } from './art-directions.js';
|
import { artDirection, directionTheme } from './art-directions.js';
|
||||||
import { attachBoardViewport } from './board-viewport.js';
|
import { attachBoardViewport } from './board-viewport.js';
|
||||||
import { TapQueue } from './tap-queue.js';
|
import { TapQueue } from './tap-queue.js';
|
||||||
import { exitTiming } from './exit-animation.js';
|
|
||||||
import { PIGMENTS, LEVEL_THEMES, themeForScore, MOTION, themeWithColors } from './design-tokens.js';
|
import { PIGMENTS, LEVEL_THEMES, themeForScore, MOTION, themeWithColors } from './design-tokens.js';
|
||||||
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
|
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
|
||||||
import { icon, installUI, decorateUI } from './ui.js';
|
import { icon, installUI, decorateUI } from './ui.js';
|
||||||
@@ -10,13 +9,13 @@ import { CAMPAIGN_VERSIONS, campaignVersion } from './campaign-versions.js';
|
|||||||
import { CAMPAIGN_SELECTION_KEY, selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.js';
|
import { CAMPAIGN_SELECTION_KEY, selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.js';
|
||||||
import { campaignLaunch, campaignPage, levelIndexFromInput, productionPresentation, createAnalysisCache } from './campaign-browser.js';
|
import { campaignLaunch, campaignPage, levelIndexFromInput, productionPresentation, createAnalysisCache } from './campaign-browser.js';
|
||||||
import { readTutorialProgress, writeTutorialProgress, tutorialLaunch } from './tutorial-progress.js';
|
import { readTutorialProgress, writeTutorialProgress, tutorialLaunch } from './tutorial-progress.js';
|
||||||
import { layout, hitCell } from './geometry.js';
|
import { layout, hitCell, center } from './geometry.js';
|
||||||
import { TILE_ATLAS } from './sprites.js';
|
import { TILE_ATLAS } from './sprites.js';
|
||||||
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
|
import { CanvasPainter, COLORS, drawScene, exitDuration } from './renderer.js';
|
||||||
const taps=new TapQueue();
|
const taps=new TapQueue();
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);
|
||||||
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
||||||
let boardViewport;
|
let boardViewport,baseView=null;
|
||||||
let progressStorage;try{progressStorage=window.localStorage;}catch{}
|
let progressStorage;try{progressStorage=window.localStorage;}catch{}
|
||||||
let saveUnavailable=!progressStorage;
|
let saveUnavailable=!progressStorage;
|
||||||
const launch=campaignLaunch(location.search,CAMPAIGN_VERSIONS,selectedCampaign(progressStorage));
|
const launch=campaignLaunch(location.search,CAMPAIGN_VERSIONS,selectedCampaign(progressStorage));
|
||||||
@@ -29,14 +28,16 @@ let tutorialProgress=readTutorialProgress(progressStorage,campaign.id,campaign.t
|
|||||||
let tutorialIndex=tutorialLaunch(location.search,campaign.tutorials?.length||0,tutorialProgress,saved);
|
let tutorialIndex=tutorialLaunch(location.search,campaign.tutorials?.length||0,tutorialProgress,saved);
|
||||||
const cachedAnalysis=createAnalysisCache(analyze);
|
const cachedAnalysis=createAnalysisCache(analyze);
|
||||||
let mapChapter=Math.floor(current/campaign.chapterSize),lastSolution=null;
|
let mapChapter=Math.floor(current/campaign.chapterSize),lastSolution=null;
|
||||||
let game=new Game(tutorialIndex===null?LEVELS[current]:campaign.tutorials[tutorialIndex]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animations=[],rotationAnimation=null,shake=null,toastTimer,view,width=0,height=0,frame=0;
|
let game=new Game(tutorialIndex===null?LEVELS[current]:campaign.tutorials[tutorialIndex]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animations=[],rotationAnimation=null,shake=null,intro=null,celebrate=null,celebrated=false,lastMoves=null,toastTimer,view,width=0,height=0,frame=0;
|
||||||
let helpUsed=false;
|
let helpUsed=false;
|
||||||
let activeTheme=null;
|
let activeTheme=null;
|
||||||
const requestedDesign=artDirection(new URLSearchParams(location.search).get('design'));
|
const requestedDesign=artDirection(new URLSearchParams(location.search).get('design'));
|
||||||
document.body.dataset.design=requestedDesign.id;
|
document.body.dataset.design=requestedDesign.id;
|
||||||
const requestedTheme=LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.search).get('theme'));
|
const requestedTheme=LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.search).get('theme'));
|
||||||
function applyTheme(score){
|
function applyTheme(score){
|
||||||
activeTheme=mode==='edit'||trial?null:directionTheme(requestedDesign,(requestedTheme||themeForScore(score)).id,Number(new URLSearchParams(location.search).get('colors'))||7);
|
// Journey designs (signpost, macaron) spread their five worlds evenly over the campaign, the last one at night.
|
||||||
|
const chapterTheme=requestedDesign.journeyWorlds?requestedDesign.themes[Math.min(requestedDesign.themes.length-1,Math.floor(current/Math.max(1,LEVELS.length)*requestedDesign.themes.length))]:null;
|
||||||
|
activeTheme=mode==='edit'||trial?null:directionTheme(requestedDesign,(requestedTheme||chapterTheme||themeForScore(score)).id,Number(new URLSearchParams(location.search).get('colors'))||7);
|
||||||
const theme=activeTheme||LEVEL_THEMES[0];
|
const theme=activeTheme||LEVEL_THEMES[0];
|
||||||
document.body.dataset.theme=theme.id;
|
document.body.dataset.theme=theme.id;
|
||||||
document.body.style.setProperty('--color-background',theme.background);
|
document.body.style.setProperty('--color-background',theme.background);
|
||||||
@@ -87,8 +88,13 @@ function renderStats(){
|
|||||||
applyTheme(production?production.themeScore:a.score);
|
applyTheme(production?production.themeScore:a.score);
|
||||||
$('level-name').textContent=production?.displayName||level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':teaching?`入门 ${teaching.step} / ${campaign.tutorials.length}`:`LEVEL ${String(current+1).padStart(2,'0')}`;
|
$('level-name').textContent=production?.displayName||level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':teaching?`入门 ${teaching.step} / ${campaign.tutorials.length}`:`LEVEL ${String(current+1).padStart(2,'0')}`;
|
||||||
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':teaching?`入门序章 / ${teaching.title}`:`第 ${Math.floor(current/chapterSize)+1} 章 / ${level.chapterName||chapters[Math.floor(current/chapterSize)]}`;
|
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':teaching?`入门序章 / ${teaching.title}`:`第 ${Math.floor(current/chapterSize)+1} 章 / ${level.chapterName||chapters[Math.floor(current/chapterSize)]}`;
|
||||||
$('moves-title').textContent=editing?'离场额度':trial?'可用额度':'待解棋子';$('remaining-title').textContent=editing?'已放置':'待离场';
|
$('moves-title').textContent=editing?'离场额度':trial?'可用额度':(requestedDesign.hud?.moves||'待解棋子');$('remaining-title').textContent=editing?'已放置':'待离场';
|
||||||
$('moves').textContent=editing?level.moveLimit:trial?game.moves:game.tiles.length;$('remaining').innerHTML=`${editing?level.tiles.length:game.tiles.length}<small> / ${editing?level.cells.length:level.tiles.length}</small>`;
|
// Stacked HUD (macaron): a small label over a big number, the reference game's 关卡 / 分数 columns.
|
||||||
|
if(requestedDesign.hud&&!editing&&!trial){const h=requestedDesign.hud;$('level-eyebrow').innerHTML=teaching?`<small>${h.tutorial}</small><b>${teaching.step}<i>/${campaign.tutorials.length}</i></b>`:`<small>${h.level}</small><b>${current+1}</b>`;}
|
||||||
|
const shownMoves=editing?level.moveLimit:trial?game.moves:game.tiles.length;$('moves').textContent=shownMoves;
|
||||||
|
// Macaron: the counter pill pops when a token leaves (the reference game's score-pill bump).
|
||||||
|
if(requestedDesign.motion?.pop&&lastMoves!==null&&shownMoves<lastMoves){const pill=$('moves').closest('.moves-bubble');if(pill){pill.classList.remove('pop');void pill.offsetWidth;pill.classList.add('pop');}}
|
||||||
|
lastMoves=shownMoves;$('remaining').innerHTML=`${editing?level.tiles.length:game.tiles.length}<small> / ${editing?level.cells.length:level.tiles.length}</small>`;
|
||||||
$('mini-caption').innerHTML=editing?'MAKE YOUR<br>OWN WAY ↗':'FOLLOW<br>THE FLOW ↗';
|
$('mini-caption').innerHTML=editing?'MAKE YOUR<br>OWN WAY ↗':'FOLLOW<br>THE FLOW ↗';
|
||||||
if(production){
|
if(production){
|
||||||
$('difficulty-label').textContent=`${production.label} · 预估`;$('score-value').textContent=`D${production.tier}`;$('score-name').textContent=production.label;$('score-bars').innerHTML='';$('score-summary').textContent=`${production.estimate} · ${level.tiles.length} 枚棋子${production.reward?' · '+production.reward:''}`;
|
$('difficulty-label').textContent=`${production.label} · 预估`;$('score-value').textContent=`D${production.tier}`;$('score-name').textContent=production.label;$('score-bars').innerHTML='';$('score-summary').textContent=`${production.estimate} · ${level.tiles.length} 枚棋子${production.reward?' · '+production.reward:''}`;
|
||||||
@@ -111,7 +117,19 @@ function renderStats(){
|
|||||||
}
|
}
|
||||||
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}<small>/100</small>`;$('editor-analysis-text').textContent=`观察难度 ${a.score}/100 · ${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转。工作量 ${a.minimumActions??'—'} 次操作,参考 ${a.workload.estimatedSeconds} 秒(估算)。仅成功离场使用 1 额度,旋转与试探免费${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 离场额度不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
|
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}<small>/100</small>`;$('editor-analysis-text').textContent=`观察难度 ${a.score}/100 · ${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转。工作量 ${a.minimumActions??'—'} 次操作,参考 ${a.workload.estimatedSeconds} 秒(估算)。仅成功离场使用 1 额度,旋转与试探免费${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 离场额度不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
|
||||||
}
|
}
|
||||||
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);const base=layout((mode==='edit'?draft:game.level).cells,width,height);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();}
|
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);const base=layout((mode==='edit'?draft:game.level).cells,width,height);baseView=base;view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();placeRotation();draw();}
|
||||||
|
// Designs with layout.rotation 'under-board' (signpost, macaron) hang the rotate controls under the board's lower slanted
|
||||||
|
// edges: from each edge midpoint drop a vertical to the tool discs' top edge and centre the button on that line's midpoint.
|
||||||
|
// layout.rim is the design's own margin around the cells. Other designs keep their layout row.
|
||||||
|
function placeRotation(){
|
||||||
|
const card=document.querySelector?.('.game-card');if(!card)return;
|
||||||
|
if(requestedDesign.layout?.rotation!=='under-board'||mode==='edit'||!baseView){for(const k of ['--rot-x','--rot-x2','--rot-y'])card.style.removeProperty(k);return;}
|
||||||
|
const s=baseView.size,cs=game.level.cells.map(c=>center(c,baseView)),rim=Math.max(1.5,s*(requestedDesign.layout.rim??.06));
|
||||||
|
const oL=Math.min(...cs.map(c=>c.x))-s-rim,oR=Math.max(...cs.map(c=>c.x))+s+rim,oT=Math.min(...cs.map(c=>c.y))-s*Math.sqrt(3)/2-rim,oB=Math.max(...cs.map(c=>c.y))+s*Math.sqrt(3)/2+rim;
|
||||||
|
const cr=canvas.getBoundingClientRect(),kr=card.getBoundingClientRect(),tool=card.querySelector('.board-tools .item-face')?.getBoundingClientRect();
|
||||||
|
const midY=cr.top-kr.top+oT+(oB-oT)*.875,toolTop=tool?tool.top-kr.top:kr.height-100;
|
||||||
|
card.style.setProperty('--rot-x',`${(cr.left-kr.left+oL+(oR-oL)*.25).toFixed(1)}px`);card.style.setProperty('--rot-x2',`${(cr.left-kr.left+oL+(oR-oL)*.75).toFixed(1)}px`);card.style.setProperty('--rot-y',`${((midY+toolTop)/2).toFixed(1)}px`);
|
||||||
|
}
|
||||||
function draw(now=performance.now()){
|
function draw(now=performance.now()){
|
||||||
if(!view)return;
|
if(!view)return;
|
||||||
ctx.clearRect(0,0,width,height);
|
ctx.clearRect(0,0,width,height);
|
||||||
@@ -126,21 +144,25 @@ function draw(now=performance.now()){
|
|||||||
if(animations.length!==activeCount)renderStats();
|
if(animations.length!==activeCount)renderStats();
|
||||||
if(rotationAnimation){const t=Math.min(1,(now-rotationAnimation.start)/rotationAnimation.duration);state.boardAngle=-rotationAnimation.direction*Math.PI/3*(1-(1-Math.pow(1-t,3)));if(t>=1){rotationAnimation=null;renderStats();}}
|
if(rotationAnimation){const t=Math.min(1,(now-rotationAnimation.start)/rotationAnimation.duration);state.boardAngle=-rotationAnimation.direction*Math.PI/3*(1-(1-Math.pow(1-t,3)));if(t>=1){rotationAnimation=null;renderStats();}}
|
||||||
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
|
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
|
||||||
|
if(intro){const t=Math.min(1,(now-intro.start)/intro.duration);state.intro=t;if(t>=1)intro=null;}
|
||||||
|
if(celebrate){const t=Math.min(1,(now-celebrate.start)/celebrate.duration);state.celebrate=t;if(t>=1)celebrate=null;}
|
||||||
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
|
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
|
||||||
if(!rotationAnimation&&mode==='play'&&!$('info-dialog').open){
|
if(!rotationAnimation&&mode==='play'&&!$('info-dialog').open){
|
||||||
const next=taps.take(game.tiles);
|
const next=taps.take(game.tiles);
|
||||||
if(next){playTile(next);return;}
|
if(next){playTile(next);return;}
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
if(animations.length||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
|
if(animations.length||rotationAnimation||shake||intro||celebrate){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
|
||||||
}
|
}
|
||||||
function toast(message,duration=3200){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),duration);}
|
function toast(message,duration=3200){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),duration);}
|
||||||
function showLesson(){if(mode==='play'&&!trial&&game.initialLevel.lesson)toast(game.initialLevel.lesson,6500);}
|
function showLesson(){if(mode==='play'&&!trial&&game.initialLevel.lesson)toast(game.initialLevel.lesson,6500);}
|
||||||
function resetEffects(){taps.clear();if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animations=[];rotationAnimation=null;for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');shake=null;path=null;hint=null;cancelAnimationFrame(frame);$('result').hidden=true;}
|
// Macaron plays a board entrance on every fresh board; other designs have motion.intro unset.
|
||||||
function startLevel(i){if(!Number.isInteger(i)||i<0||i>=LEVELS.length)return;boardViewport?.camera.reset();tutorialIndex=null;current=i;mapChapter=Math.floor(i/chapterSize);trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();showLesson();}
|
function beginIntro(){lastMoves=null;intro=requestedDesign.motion?.intro&&!reduced?{start:performance.now(),duration:requestedDesign.motion.intro}:null;}
|
||||||
|
function resetEffects(){taps.clear();intro=null;celebrate=null;celebrated=false;if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animations=[];rotationAnimation=null;for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');shake=null;path=null;hint=null;cancelAnimationFrame(frame);$('result').hidden=true;}
|
||||||
|
function startLevel(i){if(!Number.isInteger(i)||i<0||i>=LEVELS.length)return;boardViewport?.camera.reset();tutorialIndex=null;current=i;mapChapter=Math.floor(i/chapterSize);trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();beginIntro();applyMode();persist();renderMap();renderStats();resize();showLesson();}
|
||||||
function startTutorial(i){
|
function startTutorial(i){
|
||||||
if(!Number.isInteger(i)||!campaign.tutorials?.[i])return;
|
if(!Number.isInteger(i)||!campaign.tutorials?.[i])return;
|
||||||
boardViewport?.camera.reset();tutorialIndex=i;tutorialProgress={...tutorialProgress,current:i};trial=false;mode='play';game=new Game(campaign.tutorials[i]);helpUsed=false;resetEffects();applyMode();persistTutorial();renderMap();renderStats();resize();showLesson();
|
boardViewport?.camera.reset();tutorialIndex=i;tutorialProgress={...tutorialProgress,current:i};trial=false;mode='play';game=new Game(campaign.tutorials[i]);helpUsed=false;resetEffects();beginIntro();applyMode();persistTutorial();renderMap();renderStats();resize();showLesson();
|
||||||
}
|
}
|
||||||
function applyMode(){const edit=mode==='edit';document.body.classList.toggle('editing',edit);$('play-tab').classList.toggle('active',!edit);$('edit-tab').classList.toggle('active',edit);$('play-inspector').hidden=edit;$('editor-panel').hidden=!edit;$('game-tools').hidden=edit;$('editor-board-tools').hidden=!edit;$('return-editor').hidden=!trial;$('map-button').hidden=edit;$('rotation-controls').hidden=edit;}
|
function applyMode(){const edit=mode==='edit';document.body.classList.toggle('editing',edit);$('play-tab').classList.toggle('active',!edit);$('edit-tab').classList.toggle('active',edit);$('play-inspector').hidden=edit;$('editor-panel').hidden=!edit;$('game-tools').hidden=edit;$('editor-board-tools').hidden=!edit;$('return-editor').hidden=!trial;$('map-button').hidden=edit;$('rotation-controls').hidden=edit;}
|
||||||
function enterEditor(){
|
function enterEditor(){
|
||||||
@@ -154,7 +176,9 @@ function openResult(){ $('result').hidden=false;$('result').showModal();playSoun
|
|||||||
$('result').addEventListener('cancel',e=>e.preventDefault());
|
$('result').addEventListener('cancel',e=>e.preventDefault());
|
||||||
function finish(){
|
function finish(){
|
||||||
if($('result').open)return;
|
if($('result').open)return;
|
||||||
if(mode==='edit'||animations.length||rotationAnimation||$('info-dialog').open)return;
|
if(mode==='edit'||animations.length||rotationAnimation||celebrate||$('info-dialog').open)return;
|
||||||
|
// Macaron celebrates on the board first (motion.celebrate), then opens the result once the burst has settled.
|
||||||
|
if(game.won&&requestedDesign.motion?.celebrate&&!celebrated&&!reduced){celebrated=true;celebrate={start:performance.now(),duration:requestedDesign.motion.celebrate};draw();return;}
|
||||||
if(game.won){
|
if(game.won){
|
||||||
const teaching=activeTutorial();
|
const teaching=activeTutorial();
|
||||||
if(teaching){
|
if(teaching){
|
||||||
@@ -170,7 +194,7 @@ function finish(){
|
|||||||
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'这份关卡的离场额度不足':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'仍有棋子等待离场,可以返回工坊增加额度。旋转和试探都没有扣除额度。':'剩余棋子的路线被阻挡,或轨道形成了闭环。'}</p><button class="primary" id="recover">${trial?'返回编辑关卡':game.history.length?'撤销上一步':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>trial?enterEditor():game.history.length?undo():restart();$('retry').onclick=restart;
|
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'这份关卡的离场额度不足':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'仍有棋子等待离场,可以返回工坊增加额度。旋转和试探都没有扣除额度。':'剩余棋子的路线被阻挡,或轨道形成了闭环。'}</p><button class="primary" id="recover">${trial?'返回编辑关卡':game.history.length?'撤销上一步':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>trial?enterEditor():game.history.length?undo():restart();$('retry').onclick=restart;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();showLesson();}
|
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();beginIntro();renderStats();draw();finish();showLesson();}
|
||||||
function undo(){if(animations.length||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
|
function undo(){if(animations.length||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
|
||||||
function playTile(tile){
|
function playTile(tile){
|
||||||
if(rotationAnimation){if(taps.add(tile.id))draw();return;}
|
if(rotationAnimation){if(taps.add(tile.id))draw();return;}
|
||||||
@@ -180,7 +204,7 @@ function playTile(tile){
|
|||||||
for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');
|
for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');
|
||||||
const before=new Set(available(game.level,game.tiles).map(t=>t.id)),route=game.tap(tile.id);
|
const before=new Set(available(game.level,game.tiles).map(t=>t.id)),route=game.tap(tile.id);
|
||||||
if(route.status==='exit'){
|
if(route.status==='exit'){
|
||||||
playSound('exit');animations.push({route,start:performance.now(),duration:reduced?100:exitTiming(route).duration});
|
playSound('exit');animations.push({route,start:performance.now(),duration:reduced?100:exitDuration(route,activeTheme?.artStyle)});
|
||||||
const opened=available(game.level,game.tiles).filter(t=>!before.has(t.id));
|
const opened=available(game.level,game.tiles).filter(t=>!before.has(t.id));
|
||||||
if(opened.length){hint=opened[0].id;toast(`移开这一枚,${opened.length} 枚棋子的路通了!`);}
|
if(opened.length){hint=opened[0].id;toast(`移开这一枚,${opened.length} 枚棋子的路通了!`);}
|
||||||
}
|
}
|
||||||
@@ -306,7 +330,7 @@ function showMapDialog(){
|
|||||||
$('map-button').onclick=()=>{mapChapter=Math.floor(current/chapterSize);showMapDialog();};
|
$('map-button').onclick=()=>{mapChapter=Math.floor(current/chapterSize);showMapDialog();};
|
||||||
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
|
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
|
||||||
new ResizeObserver(resize).observe(canvas);
|
new ResizeObserver(resize).observe(canvas);
|
||||||
applyMode();renderMap();renderStats();resize();
|
beginIntro();applyMode();renderMap();renderStats();resize();
|
||||||
|
|
||||||
installUI();
|
installUI();
|
||||||
|
|
||||||
|
|||||||
+38
-3
@@ -2,8 +2,8 @@ import { LEVEL_THEMES, PIGMENTS, contrast, themeWithColors } from './design-toke
|
|||||||
|
|
||||||
const makePigments=(colors,labels)=>colors.map((color,i)=>({name:labels?.[i]||PIGMENTS[i].name,color,arrow:contrast('#FFFFFF',color)>=4.5?'#FFFFFF':'#172D39'}));
|
const makePigments=(colors,labels)=>colors.map((color,i)=>({name:labels?.[i]||PIGMENTS[i].name,color,arrow:contrast('#FFFFFF',color)>=4.5?'#FFFFFF':'#172D39'}));
|
||||||
const names=['轻松','进阶','烧脑','挑战','每周挑战'];
|
const names=['轻松','进阶','烧脑','挑战','每周挑战'];
|
||||||
const themes=(backgrounds,titles,materials,palettes,descriptions)=>backgrounds.map((background,i)=>({
|
const themes=(backgrounds,titles,materials,palettes,descriptions,inks)=>backgrounds.map((background,i)=>({
|
||||||
...LEVEL_THEMES[i],name:titles[i],background,ink:contrast('#12202A',background)>=4.5?'#12202A':'#FFFFFF',
|
...LEVEL_THEMES[i],name:titles[i],background,ink:inks?.[i]||(contrast('#12202A',background)>=4.5?'#12202A':'#FFFFFF'),
|
||||||
description:descriptions?.[i]||`${names[i]}主题 · ${titles[i]}。`,materials:Array.isArray(materials)?materials[i]:materials,
|
description:descriptions?.[i]||`${names[i]}主题 · ${titles[i]}。`,materials:Array.isArray(materials)?materials[i]:materials,
|
||||||
palette:palettes?.[i]||LEVEL_THEMES[i].palette,
|
palette:palettes?.[i]||LEVEL_THEMES[i].palette,
|
||||||
}));
|
}));
|
||||||
@@ -77,11 +77,46 @@ export const ART_DIRECTIONS=Object.freeze([
|
|||||||
[[0,1,2,3,4,5,7],[0,1,3,6,8,9,4],[1,2,3,5,9,10,0],[0,1,4,6,7,10,11],[0,2,3,4,5,8,10]],
|
[[0,1,2,3,4,5,7],[0,1,3,6,8,9,4],[1,2,3,5,9,10,0],[0,1,4,6,7,10,11],[0,2,3,4,5,8,10]],
|
||||||
['保留实验页的雾蓝背景、暖白格面与陶橙/雾蓝棋子,扩展色同样保持哑光。','像暖砂色纸上的铅笔路线,陶橙、赭金与木棕带来温度。','浅鼠尾草纸底,灰绿轨道与蓝、苔绿棋子形成安静的冷暖关系。','灰白图纸与石墨蓝细线,用陶红、雾紫和钢蓝组织复杂路线。','深蓝灰图纸上保留细线与白箭头,暖色棋子像夜航中的小标记。']),
|
['保留实验页的雾蓝背景、暖白格面与陶橙/雾蓝棋子,扩展色同样保持哑光。','像暖砂色纸上的铅笔路线,陶橙、赭金与木棕带来温度。','浅鼠尾草纸底,灰绿轨道与蓝、苔绿棋子形成安静的冷暖关系。','灰白图纸与石墨蓝细线,用陶红、雾紫和钢蓝组织复杂路线。','深蓝灰图纸上保留细线与白箭头,暖色棋子像夜航中的小标记。']),
|
||||||
pigments:makePigments(['#D66C4D','#477FC0','#668877','#B58943','#8B7297','#45868C','#B76260','#607B90','#B28168','#46765F','#6B8AAB','#9C7284'],['陶橙','雾蓝','鼠尾草','赭金','雾紫','湖青','陶红','钢蓝','暖木','松绿','烟蓝','藕灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
|
pigments:makePigments(['#D66C4D','#477FC0','#668877','#B58943','#8B7297','#45868C','#B76260','#607B90','#B28168','#46765F','#6B8AAB','#9C7284'],['陶橙','雾蓝','鼠尾草','赭金','雾紫','湖青','陶红','钢蓝','暖木','松绿','烟蓝','藕灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
|
||||||
|
{id:'signpost',code:'06',name:'路标',en:'SIGNPOST',tag:'线路图',style:'signpost',accent:'#2F5FBF',paper:'#F6F2E9',journeyWorlds:true,layout:{rotation:'under-board',rim:.06},
|
||||||
|
description:'棋盘是一张线路图:奶白卡片托在世界色上,墨蓝钢轨压在浅色路基里,端点在板边收口。棋子是路标:十二色统一白箭头,一圈细描边,方向一眼可读。',
|
||||||
|
principles:['格线几乎不可见,路基与钢轨分两层,交叉不打架','棋子内缩露出端口,细描边加统一放大的白箭头','旋转按钮像压在纸上的印记,挂在棋盘两肩之下'],
|
||||||
|
route:{exit:'#2F5FBF',exitGlow:'#C9D7F3',blocked:'#C4432F',blockedGlow:'#F3D3CB',hint:'#D9A227'},
|
||||||
|
themes:themes(
|
||||||
|
['#B7CBD3','#DBC4A6','#BBCBB1','#D7BAB5','#1F2C3B'],
|
||||||
|
['晨雾','杏仁','苔原','暮砂','夜航'],
|
||||||
|
[
|
||||||
|
{board:'#F5F2EA',boardGrid:'#E9E3D6',track:'#3A5068',trackHalo:'#E2DBCC',rim:'#7B8F9C',base:'#95ACB6'},
|
||||||
|
{board:'#F7F2E7',boardGrid:'#EAE3D3',track:'#3A5068',trackHalo:'#E4DCCA',rim:'#98836A',base:'#BFA483'},
|
||||||
|
{board:'#F5F3E9',boardGrid:'#E8E4D6',track:'#3A5068',trackHalo:'#E1DCCC',rim:'#7E9077',base:'#9EB194'},
|
||||||
|
{board:'#F7F1EA',boardGrid:'#EAE2D8',track:'#3A5068',trackHalo:'#E4DBCF',rim:'#9D817D',base:'#BB9C97'},
|
||||||
|
{board:'#F6F2E9',boardGrid:'#E9E3D6',track:'#3A5068',trackHalo:'#E2DBCC',rim:'#55697C',base:'#121C27'},
|
||||||
|
],
|
||||||
|
[[0,2,5,3,1,8,6],[1,0,2,6,3,9,7],[3,6,1,0,4,10,2],[6,0,3,5,2,11,8],[2,5,0,6,1,9,3]],
|
||||||
|
['晨雾蓝灰世界色配奶白卡片,靛蓝、松石与琥珀领色。','杏仁暖砂世界色,朱砂与靛蓝领色,像午后的纸面。','苔原灰绿世界色,紫藤与玫红提亮。','暮砂玫瑰灰世界色,玫红与靛蓝对照。','夜航深蓝灰世界色,奶白卡片像被灯照亮的图。']),
|
||||||
|
pigments:makePigments(['#2F5FBF','#C4432F','#1C8378','#7A4EB8','#4A8235','#A8690C','#BF3A6B','#4A6B8A','#B0572F','#357358','#8A5C92','#5A6D7C'],['靛蓝','朱砂','松石','紫藤','苔绿','琥珀','玫红','钢蓝','赤陶','墨绿','藕紫','铁灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
|
||||||
|
{id:'macaron',code:'07',name:'马卡龙',en:'MACARON',tag:'参考 Meowdoku',style:'macaron',accent:'#E07416',paper:'#FBF6F0',journeyWorlds:true,layout:{rotation:'under-board',rim:.40},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'},
|
||||||
|
description:'奶油色世界上托一张白色软托盘,六角格像带白缝的软糖砖,藕紫轨道压在糖砖上。棋子是糖果色圆角六角,白色贴纸边,粗圆头白箭头。反馈用果糖色说话:路线点亮、糖砖出场、离场撒星。',
|
||||||
|
principles:['白托盘加白缝,格与格靠留白分开,不画格线','十二色统一白色粗箭头,与白缝同一套白','状态用颜色和弹跳表达:路线点亮、碰撞橘红、出场从左上角一波弹开'],
|
||||||
|
route:{blocked:'#F0663A',hint:'#F7C948'},
|
||||||
|
themes:themes(
|
||||||
|
['#F5EFE7','#FBE6DA','#EEE8F6','#F9E2E8','#3E2E35'],
|
||||||
|
['奶油','蜜桃','香芋','草莓牛奶','可可夜'],
|
||||||
|
[
|
||||||
|
{board:'#FADBCB',boardGrid:'#FFFFFF',track:'#96707B',trackHalo:'#FFFFFF',rings:['#FADBCB','#FAE9BE','#D6EEE1'],tray:'#FFFFFF',shadow:'#E6DDD6',shadowSoft:'#EEE7E0',blush:'#F9B9A6',blocked:'#F0663A',hint:'#F7C948',hintGlow:'#FCE7B5'},
|
||||||
|
{board:'#FDE3D3',boardGrid:'#FFFFFF',track:'#976A66',trackHalo:'#FFFFFF',rings:['#FDE3D3','#FBEDC4','#F4D9E2'],tray:'#FFFFFF',shadow:'#EBD5C9',shadowSoft:'#F3DED4',blush:'#F9B9A6',blocked:'#F0663A',hint:'#F7C948',hintGlow:'#FCE7B5'},
|
||||||
|
{board:'#E9E0F6',boardGrid:'#FFFFFF',track:'#78678F',trackHalo:'#FFFFFF',rings:['#E9E0F6','#F8E3EA','#D9EAF1'],tray:'#FFFFFF',shadow:'#DED6EA',shadowSoft:'#E6DFF0',blush:'#F9B9A6',blocked:'#F0663A',hint:'#F7C948',hintGlow:'#FCE7B5'},
|
||||||
|
{board:'#FBDDE5',boardGrid:'#FFFFFF',track:'#966076',trackHalo:'#FFFFFF',rings:['#FBDDE5','#FDEBCF','#E3EEDA'],tray:'#FFFFFF',shadow:'#EAD0D8',shadowSoft:'#F1D9E0',blush:'#F9B9A6',blocked:'#F0663A',hint:'#F7C948',hintGlow:'#FCE7B5'},
|
||||||
|
{board:'#F9E3D6',boardGrid:'#FFFFFF',track:'#8A6370',trackHalo:'#FFFFFF',rings:['#F9E3D6','#FAEBC8','#DAEBE3'],tray:'#FFF9F3',shadow:'#2A1E23',shadowSoft:'#34272C',blush:'#F9B9A6',blocked:'#F0663A',hint:'#F7C948',hintGlow:'#FCE7B5'},
|
||||||
|
],
|
||||||
|
[[0,5,3,2,6,1,4],[1,9,0,4,2,10,3],[6,7,4,2,11,5,3],[7,3,5,2,11,8,1],[4,0,2,6,1,9,3]],
|
||||||
|
['奶油底、白托盘,草莓、蓝莓与抹茶领色,像一盒刚打开的马卡龙。','蜜桃奶油底,蜜柑与海盐蓝领色,暖一点、甜一点。','香芋奶油底,香芋紫与玫瑰领色,冷一点、静一点。','草莓牛奶底,玫瑰与抹茶对照,白托盘更亮。','可可夜底,白托盘像被灯照亮的甜品盘,薄荷与草莓最先出现。'],
|
||||||
|
['#7A5560','#7C4E52','#5F4E78','#7B4658','#FBEFE6']),
|
||||||
|
pigments:makePigments(['#EE5398','#E5680C','#AB8716','#4C9E5A','#369C94','#578BF0','#937DE4','#D06E93','#B5806A','#4A91D4','#8C68CC','#C56AC4'],['草莓','蜜柑','蜂蜜','抹茶','薄荷','蓝莓','香芋','玫瑰','可可','海盐','葡萄','洋李']).map(p=>({...p,arrow:'#FFFFFF'}))},
|
||||||
]);
|
]);
|
||||||
export function artDirection(id){return ART_DIRECTIONS.find(d=>d.id===id)||ART_DIRECTIONS[0];}
|
export function artDirection(id){return ART_DIRECTIONS.find(d=>d.id===id)||ART_DIRECTIONS[0];}
|
||||||
export function directionTheme(direction,themeId,count=7){
|
export function directionTheme(direction,themeId,count=7){
|
||||||
const source=direction.themes.find(t=>t.id===themeId)||direction.themes[0];
|
const source=direction.themes.find(t=>t.id===themeId)||direction.themes[0];
|
||||||
return {...themeWithColors(source,count),artStyle:direction.style,pigments:direction.pigments};
|
return {...themeWithColors(source,count),artStyle:direction.style,pigments:direction.pigments,route:direction.route};
|
||||||
}
|
}
|
||||||
export function appearanceTile(tile,theme){
|
export function appearanceTile(tile,theme){
|
||||||
const color=theme?.pigments?.[tile.color];
|
const color=theme?.pigments?.[tile.color];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ARCHIVED_LEVELS } from './levels-archive.js';
|
|||||||
import { EXPERIENCE_CAMPAIGN, EXPERIENCE_LEVELS } from './levels-v04.js';
|
import { EXPERIENCE_CAMPAIGN, EXPERIENCE_LEVELS } from './levels-v04.js';
|
||||||
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from './levels-v05.js';
|
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from './levels-v05.js';
|
||||||
import { TUTORIAL_LEVELS } from './tutorial-levels.js';
|
import { TUTORIAL_LEVELS } from './tutorial-levels.js';
|
||||||
|
import { C50_CAMPAIGN, C50_LEVELS } from './levels-c50.js';
|
||||||
|
|
||||||
// Public layout versions are distinct from the level JSON schema's `version: 1`.
|
// Public layout versions are distinct from the level JSON schema's `version: 1`.
|
||||||
// Historical layouts stay intact; all editions share the current game rules.
|
// Historical layouts stay intact; all editions share the current game rules.
|
||||||
@@ -32,6 +33,10 @@ export const CAMPAIGN_VERSIONS = [
|
|||||||
chapters:PRODUCTION_CAMPAIGN.chapters, chapterSize:PRODUCTION_CAMPAIGN.chapterSize,
|
chapters:PRODUCTION_CAMPAIGN.chapters, chapterSize:PRODUCTION_CAMPAIGN.chapterSize,
|
||||||
levels:PRODUCTION_LEVELS, tutorials:TUTORIAL_LEVELS, legacyStorageKeys:[],
|
levels:PRODUCTION_LEVELS, tutorials:TUTORIAL_LEVELS, legacyStorageKeys:[],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id:C50_CAMPAIGN.id, name:C50_CAMPAIGN.name, description:C50_CAMPAIGN.description,
|
||||||
|
chapters:C50_CAMPAIGN.chapters, chapterSize:C50_CAMPAIGN.chapterSize, levels:C50_LEVELS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
export const DEFAULT_CAMPAIGN_VERSION = 'v0.5';
|
export const DEFAULT_CAMPAIGN_VERSION = 'v0.5';
|
||||||
export function campaignVersion(id) {
|
export function campaignVersion(id) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+134
-17
@@ -1,4 +1,4 @@
|
|||||||
import { drawStyledToken } from './token-art.js';
|
import { drawStyledToken, tint } from './token-art.js';
|
||||||
import { appearanceTile } from './art-directions.js';
|
import { appearanceTile } from './art-directions.js';
|
||||||
import { exitTiming, smoothMotion, travelProgress } from './exit-animation.js';
|
import { exitTiming, smoothMotion, travelProgress } from './exit-animation.js';
|
||||||
import { THEME, PIGMENTS, pigmentIndex } from './design-tokens.js';
|
import { THEME, PIGMENTS, pigmentIndex } from './design-tokens.js';
|
||||||
@@ -8,9 +8,14 @@ import { collisionPhase, contactPath } from './collision-animation.js';
|
|||||||
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
|
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
|
||||||
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
|
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
|
||||||
export const COLORS=PIGMENTS.map(p=>p.color);
|
export const COLORS=PIGMENTS.map(p=>p.color);
|
||||||
|
// Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window.
|
||||||
|
const MACARON_DISSOLVE_MS=560;
|
||||||
|
export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;}
|
||||||
|
// Four-point sparkle star used by the macaron win celebration.
|
||||||
|
const sparkle=(x,y,r)=>Array.from({length:8},(_,j)=>{const b=j*Math.PI/4-Math.PI/2,rr=j%2?r*.38:r;return {x:x+Math.cos(b)*rr,y:y+Math.sin(b)*rr};});
|
||||||
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
|
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
|
||||||
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
|
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
|
||||||
const r=size*(tile.artStyle==='paper'?.88:TILE_RADIUS)*scale;
|
const r=size*(tile.artStyle==='paper'?.88:tile.artStyle==='signpost'?.90:tile.artStyle==='macaron'?.90:TILE_RADIUS)*scale;
|
||||||
if(drawStyledToken(p,x,y,r,tile,angle))return;
|
if(drawStyledToken(p,x,y,r,tile,angle))return;
|
||||||
const textured=p.sprite?.(tile.color,x,y,r)===true;
|
const textured=p.sprite?.(tile.color,x,y,r)===true;
|
||||||
if(!textured){
|
if(!textured){
|
||||||
@@ -25,7 +30,8 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
const {size}=view;
|
const {size}=view;
|
||||||
const visualTile=tile=>appearanceTile({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)},state.theme);
|
const visualTile=tile=>appearanceTile({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)},state.theme);
|
||||||
const materials={...THEME,...state.theme?.materials};
|
const materials={...THEME,...state.theme?.materials};
|
||||||
const paper=state.theme?.artStyle==='paper';
|
const paper=state.theme?.artStyle==='paper',signpost=state.theme?.artStyle==='signpost',macaron=state.theme?.artStyle==='macaron';
|
||||||
|
const roadbedWidth=Math.min(18,Math.max(5,size*.30)),railWidth=Math.min(7,Math.max(2.5,size*.105)),gummyWidth=Math.min(6.5,Math.max(2.2,size*.11));
|
||||||
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
|
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
|
||||||
const rotate=points=>points.map(pt=>{const x=pt.x-view.ox,y=pt.y-view.oy;return {x:view.ox+x*cos-y*sin,y:view.oy+x*sin+y*cos};});
|
const rotate=points=>points.map(pt=>{const x=pt.x-view.ox,y=pt.y-view.oy;return {x:view.ox+x*cos-y*sin,y:view.oy+x*sin+y*cos};});
|
||||||
const base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
|
const base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
|
||||||
@@ -44,47 +50,121 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
const movingIds=new Set(motions.map(a=>a.route.tile.id));
|
const movingIds=new Set(motions.map(a=>a.route.tile.id));
|
||||||
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
|
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
|
||||||
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
|
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
|
||||||
|
// Macaron: cells pop in as a wave from the top-left corner (state.intro 0–1); without an intro every cell is at scale 1.
|
||||||
|
const cellScale=(()=>{
|
||||||
|
if(!macaron||!(state.intro<1))return ()=>1;
|
||||||
|
const cs=level.cells.map(c=>({c,p:center(c,view)})),xs=cs.map(o=>o.p.x),ys=cs.map(o=>o.p.y),x0=Math.min(...xs),x1=Math.max(...xs),y0=Math.min(...ys),y1=Math.max(...ys);
|
||||||
|
const order=new Map(cs.map(({c,p})=>[key(c.q,c.r),((x1>x0?(p.x-x0)/(x1-x0):0)+(y1>y0?(p.y-y0)/(y1-y0):0))/2]));
|
||||||
|
return cell=>{const t=Math.max(0,Math.min(1,(state.intro-order.get(key(cell.q,cell.r))*.55)/.45)),u=t-1;return t<=0?0:1+2.2*u*u*u+1.2*u*u;};
|
||||||
|
})();
|
||||||
|
// Macaron: cell tints follow concentric rings from the board centre, so they stay put through every rotation.
|
||||||
|
const ringTint=(()=>{
|
||||||
|
if(!macaron)return ()=>materials.board;
|
||||||
|
const rings=materials.rings||[materials.board],cq=level.cells.reduce((a,c)=>a+c.q,0)/level.cells.length,cr=level.cells.reduce((a,c)=>a+c.r,0)/level.cells.length;
|
||||||
|
let origin=level.cells[0],best=Infinity;for(const c of level.cells){const d=Math.hypot(c.q-cq,c.r-cr);if(d<best){best=d;origin=c;}}
|
||||||
|
return cell=>{const dq=cell.q-origin.q,dr=cell.r-origin.r;return rings[Math.max(Math.abs(dq),Math.abs(dr),Math.abs(dq+dr))%rings.length];};
|
||||||
|
})();
|
||||||
|
// Macaron: cells along a previewed or travelling route blush in the token's own tint; a blocked cell blushes orange-cream.
|
||||||
|
// A tapped route carries its tile; a traced preview does not, so the tile is looked up at the route's first step.
|
||||||
|
const routeFill=route=>{const start=route.steps?.[0],tile=route.tile||(start&&tiles.find(t=>t.q===start.q&&t.r===start.r));return (tile&&visualTile(tile).fill)||materials.track;};
|
||||||
|
const blushCells=new Map();
|
||||||
|
if(macaron){
|
||||||
|
const blush=(steps,color)=>{for(const s of steps||[])blushCells.set(key(s.q,s.r),color);};
|
||||||
|
for(const m of motions)if(m.route.status==='exit')blush(m.route.steps,tint(routeFill(m.route),.84));
|
||||||
|
if(state.path)blush(state.path.steps,state.path.status==='exit'?tint(routeFill(state.path),.80):materials.blush);
|
||||||
|
for(const m of motions)if(m.blocker&&m.phase.impact>0)blushCells.set(key(m.blocker.q,m.blocker.r),materials.blush);
|
||||||
|
}
|
||||||
|
// Macaron: one white tray under everything, the rounded convex hull of the cells plus a margin, lifted by two opaque
|
||||||
|
// shadow steps. The tray turns with the board; the shadow offset stays vertical on screen.
|
||||||
|
if(macaron){
|
||||||
|
const pad=size*.34,cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
|
||||||
|
const hull=pts=>{const s=[...pts].sort((a,b)=>a.x-b.x||a.y-b.y),lower=[],upper=[];for(const q of s){while(lower.length>=2&&cross(lower[lower.length-2],lower[lower.length-1],q)<=0)lower.pop();lower.push(q);}for(const q of s.reverse()){while(upper.length>=2&&cross(upper[upper.length-2],upper[upper.length-1],q)<=0)upper.pop();upper.push(q);}return [...lower.slice(0,-1),...upper.slice(0,-1)];};
|
||||||
|
const rounded=(poly,radius)=>poly.flatMap((v,i)=>{const prev=poly[(i+poly.length-1)%poly.length],next=poly[(i+1)%poly.length],ka=Math.min(.5,radius/Math.hypot(prev.x-v.x,prev.y-v.y)),kb=Math.min(.5,radius/Math.hypot(next.x-v.x,next.y-v.y)),a={x:v.x+(prev.x-v.x)*ka,y:v.y+(prev.y-v.y)*ka},b={x:v.x+(next.x-v.x)*kb,y:v.y+(next.y-v.y)*kb};return Array.from({length:6},(_,j)=>{const t=j/5,u=1-t;return {x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y};});});
|
||||||
|
const tray=grow=>rounded(hull(level.cells.flatMap(c=>{const {x,y}=center(c,view);return hexPoints(x,y,size+pad+grow);})),size*.9);
|
||||||
|
for(const [grow,dy,color] of [[size*.06,size*.20,materials.shadowSoft],[0,size*.10,materials.shadow]])p.polygon(rotate(tray(grow)).map(q=>({x:q.x,y:q.y+dy})),color);
|
||||||
|
base.polygon(tray(0),materials.tray);
|
||||||
|
}
|
||||||
|
// Signpost: a flat offset base and a rim under the cells lift the card off the world colour.
|
||||||
|
// The base offset stays vertical on screen, so it is applied after board rotation.
|
||||||
|
if(signpost){
|
||||||
|
const rimW=Math.max(1.5,size*.06),baseDy=size*.12;
|
||||||
|
for(const cell of sorted){const {x,y}=center(cell,view);p.polygon(rotate(hexPoints(x,y,size+.2+rimW)).map(q=>({x:q.x,y:q.y+baseDy})),materials.base);}
|
||||||
|
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2+rimW),materials.rim);}
|
||||||
|
}
|
||||||
// Paint cells before rails. Paper keeps the reference's narrow gaps; other
|
// Paint cells before rails. Paper keeps the reference's narrow gaps; other
|
||||||
// styles overlap slightly to avoid antialiasing seams.
|
// styles overlap slightly to avoid antialiasing seams.
|
||||||
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board);}
|
|
||||||
// Cell boundaries stay beneath the connected track endpoints.
|
|
||||||
for(const cell of sorted){
|
for(const cell of sorted){
|
||||||
|
const {x,y}=center(cell,view);
|
||||||
|
if(macaron){const sc=cellScale(cell);if(sc>.02)base.polygon(roundedHex(x,y,size*.90*sc,.16),blushCells.get(key(cell.q,cell.r))||ringTint(cell));continue;}
|
||||||
|
base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board);
|
||||||
|
}
|
||||||
|
// Cell boundaries stay beneath the connected track endpoints. Macaron separates cells with white gutters instead.
|
||||||
|
if(!macaron)for(const cell of sorted){
|
||||||
const {x,y}=center(cell,view),edge=hexPoints(x,y,paper?size*.97:size);
|
const {x,y}=center(cell,view),edge=hexPoints(x,y,paper?size*.97:size);
|
||||||
base.line([...edge,edge[0]],materials.boardGrid,paper?Math.max(.4,size*.025):Math.max(1,size*.024));
|
base.line([...edge,edge[0]],materials.boardGrid,paper?Math.max(.4,size*.025):signpost?Math.max(.6,size*.02):Math.max(1,size*.024));
|
||||||
}
|
}
|
||||||
const rails=[];
|
const rails=[];
|
||||||
for(const cell of sorted) {
|
for(const cell of sorted) {
|
||||||
const {x,y}=center(cell,view);
|
const {x,y}=center(cell,view),sc=cellScale(cell);
|
||||||
|
if(sc<=.15)continue;
|
||||||
for(let a=0;a<6;a++) if(a<cell.tracks[a]) {
|
for(let a=0;a<6;a++) if(a<cell.tracks[a]) {
|
||||||
// Inset the centerline by half the widest stroke at each hex side.
|
// Inset the centerline by half the widest stroke at each hex side.
|
||||||
// Rounded line caps must also remain inside the visible cell.
|
// Rounded line caps must also remain inside the visible cell.
|
||||||
const trackWidth=Math.max(4,size*.16);
|
const trackWidth=signpost?roadbedWidth:macaron?gummyWidth:Math.max(4,size*.16);
|
||||||
const visualRadius=Math.max(0,size*.965-trackWidth/Math.sqrt(3)-.5);
|
// Rails run continuously across macaron's white gutters; only board-edge ends are pulled inside the brick face.
|
||||||
|
const visualRadius=Math.max(0,(macaron?size*.90:size*.965)-trackWidth/Math.sqrt(3)-.5);
|
||||||
const b=cell.tracks[a],points=trackPoints(x,y,size,a,b);
|
const b=cell.tracks[a],points=trackPoints(x,y,size,a,b);
|
||||||
const exterior=d=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
const exterior=d=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
||||||
const first=points[0],last=points[points.length-1],shrink=1-visualRadius/size;
|
const first=points[0],last=points[points.length-1],shrink=1-visualRadius/size;
|
||||||
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
|
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
|
||||||
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
|
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
|
||||||
if(!paper)points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
|
if(!paper)points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
|
||||||
|
if(sc<1)points.forEach(pt=>{pt.x=x+(pt.x-x)*sc;pt.y=y+(pt.y-y)*sc;});
|
||||||
rails.push({points,primary:!state.trackEmphasis||state.trackEmphasis(cell,a,b)});
|
rails.push({points,primary:!state.trackEmphasis||state.trackEmphasis(cell,a,b)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pattern studies can emphasize real routes while retaining the other paired
|
// Pattern studies can emphasize real routes while retaining the other paired
|
||||||
// connections as secondary lines. Ordinary levels keep the same drawing passes.
|
// connections as secondary lines. Ordinary levels keep the same drawing passes.
|
||||||
for(const {points,primary} of rails)if(!primary)base.line(points,state.secondaryTrackColor||materials.boardGrid,Math.max(1,size*.025));
|
for(const {points,primary} of rails)if(!primary)base.line(points,state.secondaryTrackColor||materials.boardGrid,Math.max(1,size*.025));
|
||||||
if(!paper)for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,Math.max(4,size*.16));
|
if(!paper&&!macaron)for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,signpost?roadbedWidth:Math.max(4,size*.16));
|
||||||
for(const {points,primary} of rails)if(primary)base.line(points,materials.track,paper?Math.max(.7,size*.055):Math.max(2.5,size*.10));
|
for(const {points,primary} of rails)if(primary)base.line(points,materials.track,paper?Math.max(.7,size*.055):signpost?railWidth:macaron?gummyWidth:Math.max(2.5,size*.10));
|
||||||
if(state.path) p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));
|
// Route preview: classic styles draw it under the tokens; signpost draws it over them, in two layers, ending at the rim.
|
||||||
|
// Macaron draws the line under the tokens but its blocked cross above them, so a short stub still shows its cross.
|
||||||
|
let afterTokens=null;
|
||||||
|
const drawPath=()=>{
|
||||||
|
if(!state.path)return;
|
||||||
|
if(macaron){
|
||||||
|
// One chunky line in the token's own colour over the rails; a blocked route stops at the contact point with an orange cross.
|
||||||
|
const exit=state.path.status==='exit',fill=routeFill(state.path);
|
||||||
|
let points=routePoints(state.path,view);if(exit&&!state.path.travelPoints&&points.length>2)points=points.slice(0,-1);
|
||||||
|
p.line(points,exit?fill:materials.blocked,gummyWidth*1.15);
|
||||||
|
if(!exit)afterTokens=()=>{const q=points[points.length-1],k=gummyWidth*1.2;p.line([{x:q.x-k,y:q.y-k},{x:q.x+k,y:q.y+k}],materials.blocked,gummyWidth);p.line([{x:q.x-k,y:q.y+k},{x:q.x+k,y:q.y-k}],materials.blocked,gummyWidth);};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!signpost){p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));return;}
|
||||||
|
const exit=state.path.status==='exit',route=state.theme.route||{};
|
||||||
|
let points=routePoints(state.path,view);if(exit&&!state.path.travelPoints&&points.length>2)points=points.slice(0,-1);
|
||||||
|
p.line(points,exit?route.exitGlow:route.blockedGlow,roadbedWidth*1.05);p.line(points,exit?route.exit:route.blocked,railWidth*1.5);
|
||||||
|
if(!exit){const q=points[points.length-1],k=railWidth*1.4;p.line([{x:q.x-k,y:q.y-k},{x:q.x+k,y:q.y+k}],route.blocked,railWidth);p.line([{x:q.x-k,y:q.y+k},{x:q.x+k,y:q.y-k}],route.blocked,railWidth);}
|
||||||
|
};
|
||||||
|
if(!signpost)drawPath();
|
||||||
if(state.selected) {
|
if(state.selected) {
|
||||||
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
|
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
|
||||||
if(cell){const c=center(cell,view),pts=hexPoints(c.x,c.y,size*.98);p.line([...pts,pts[0]],THEME.reward,3);}
|
if(cell){const c=center(cell,view),pts=hexPoints(c.x,c.y,size*.98);p.line([...pts,pts[0]],THEME.reward,3);}
|
||||||
}
|
}
|
||||||
for(const tile of [...tiles].filter(t=>!movingIds.has(t.id)).sort((a,b)=>center(a,view).y-center(b,view).y)) {
|
for(const tile of [...tiles].filter(t=>!movingIds.has(t.id)).sort((a,b)=>center(a,view).y-center(b,view).y)) {
|
||||||
const c=center(tile,view);
|
const c=center(tile,view),sc=cellScale(tile);
|
||||||
if(tile.id===state.hint) {p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);}
|
// Macaron: a tray-white seat under every resting token covers the rail crossings in the gutters around its brick,
|
||||||
|
// so no rail end shows at the token's edge; when the token leaves, the continuous rail reappears.
|
||||||
|
if(macaron&&sc>=.3)p.polygon(roundedHex(c.x,c.y,size*1.12*sc,.16),materials.tray);
|
||||||
|
if(tile.id===state.hint) {
|
||||||
|
if(macaron){p.polygon(roundedHex(c.x,c.y,size*1.02,.16),materials.hintGlow);p.polygon(roundedHex(c.x,c.y,size*.94,.16),materials.hint);}
|
||||||
|
else if(signpost){const ring=Array.from({length:48},(_,i)=>({x:c.x+Math.cos(i/48*Math.PI*2)*size*1.1,y:c.y+Math.sin(i/48*Math.PI*2)*size*1.1}));p.line([...ring,ring[0]],state.theme.route?.hint||THEME.reward,Math.max(2.5,size*.08));}
|
||||||
|
else{p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);}
|
||||||
|
}
|
||||||
if(state.queued?.includes(tile.id)){
|
if(state.queued?.includes(tile.id)){
|
||||||
const ring=hexPoints(c.x,c.y,size*1.03);
|
const ring=hexPoints(c.x,c.y,size*1.03);
|
||||||
p.line([...ring,ring[0]],THEME.primary,3);
|
p.line([...ring,ring[0]],macaron?materials.track:THEME.primary,3);
|
||||||
}
|
}
|
||||||
let dx=0;
|
let dx=0;
|
||||||
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
|
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
|
||||||
@@ -92,7 +172,17 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
for(const {blocker,impactAngle,phase} of motions)if(tile.id===blocker?.id){
|
for(const {blocker,impactAngle,phase} of motions)if(tile.id===blocker?.id){
|
||||||
dx+=Math.cos(impactAngle)*phase.kick*size*.32;dy+=Math.sin(impactAngle)*phase.kick*size*.32;
|
dx+=Math.cos(impactAngle)*phase.kick*size*.32;dy+=Math.sin(impactAngle)*phase.kick*size*.32;
|
||||||
}
|
}
|
||||||
drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile));
|
if(sc>=.3)drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile),undefined,sc);
|
||||||
|
}
|
||||||
|
if(signpost)drawPath();
|
||||||
|
afterTokens?.();
|
||||||
|
// Macaron: when the last token has left, twelve candy stars and a honey ring burst out of the tray before the result opens.
|
||||||
|
if(macaron&&state.celebrate!==undefined&&state.celebrate<1){
|
||||||
|
const t=state.celebrate,ease=1-Math.pow(1-t,3),cs=level.cells.map(c=>center(c,view)),cx=cs.reduce((a,c)=>a+c.x,0)/cs.length,cy=cs.reduce((a,c)=>a+c.y,0)/cs.length;
|
||||||
|
const reach=Math.max(...cs.map(c=>Math.hypot(c.x-cx,c.y-cy)))+size*1.3,colors=(state.theme.palette||[]).map(i=>state.theme.pigments[i].color);
|
||||||
|
const ringR=reach*(.45+.6*ease),ring=Array.from({length:48},(_,i)=>({x:cx+Math.cos(i/48*Math.PI*2)*ringR,y:cy+Math.sin(i/48*Math.PI*2)*ringR}));
|
||||||
|
p.line([...ring,ring[0]],materials.hint+Math.round((1-t)*200).toString(16).padStart(2,'0'),Math.max(1,size*.14*(1-t)));
|
||||||
|
for(let i=0;i<12;i++){const a=i/12*Math.PI*2-Math.PI/2+ease*.4,d=reach*(.3+.8*ease),r=size*.34*(1-smoothMotion(t)),sx=cx+Math.cos(a)*d,sy=cy+Math.sin(a)*d;if(r>0)p.polygon(sparkle(sx,sy,r),colors.length?colors[i%colors.length]:materials.hint);}
|
||||||
}
|
}
|
||||||
for(const {route,progress,target,phase,movingPath,contact,impactAngle} of motions) {
|
for(const {route,progress,target,phase,movingPath,contact,impactAngle} of motions) {
|
||||||
const blocked=route.status!=='exit';
|
const blocked=route.status!=='exit';
|
||||||
@@ -104,6 +194,7 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
}
|
}
|
||||||
if(target&&phase.impact>0){
|
if(target&&phase.impact>0){
|
||||||
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
|
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
|
||||||
|
if(macaron){const k=size*.30*phase.impact;p.line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,gummyWidth);p.line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,gummyWidth);continue;}
|
||||||
for(const offset of [-1.95,-1.2,1.2,1.95]){const a=impactAngle+offset,d=size*(.12+phase.impact*.16),length=size*.26*phase.impact;p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+length),y:y+Math.sin(a)*(d+length)}],materials.track,Math.max(1.5,size*.055));}
|
for(const offset of [-1.95,-1.2,1.2,1.95]){const a=impactAngle+offset,d=size*(.12+phase.impact*.16),length=size*.26*phase.impact;p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+length),y:y+Math.sin(a)*(d+length)}],materials.track,Math.max(1.5,size*.055));}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,13 +223,14 @@ export function exitGeometry(route,view){
|
|||||||
export function drawExit(p,route,view,progress,tile,reduced=false){
|
export function drawExit(p,route,view,progress,tile,reduced=false){
|
||||||
const {points,edge,angle}=exitGeometry(route,view),size=view.size;
|
const {points,edge,angle}=exitGeometry(route,view),size=view.size;
|
||||||
if(reduced){if(progress<.8){const at=pointAlong(points,progress/.8);drawTile(p,at.x,at.y,size,tile,at.angle);}return;}
|
if(reduced){if(progress<.8){const at=pointAlong(points,progress/.8);drawTile(p,at.x,at.y,size,tile,at.angle);}return;}
|
||||||
const timing=exitTiming(route),travel=timing.travelFraction;
|
const timing=exitTiming(route),travel=timing.travelMs/exitDuration(route,tile.artStyle);
|
||||||
if(progress<travel){
|
if(progress<travel){
|
||||||
const at=pointAlong(points,travelProgress(progress/travel,timing.travelMs));
|
const at=pointAlong(points,travelProgress(progress/travel,timing.travelMs));
|
||||||
drawTile(p,at.x,at.y,size,tile,at.angle);return;
|
drawTile(p,at.x,at.y,size,tile,at.angle);return;
|
||||||
}
|
}
|
||||||
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
|
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
|
||||||
const color=tile.fill||COLORS[tile.color],dx=Math.cos(angle),dy=Math.sin(angle);
|
const color=tile.fill||COLORS[tile.color],dx=Math.cos(angle),dy=Math.sin(angle);
|
||||||
|
if(tile.artStyle==='macaron'){bubbleDissolve(p,edge,angle,size,tile,color,t);return;}
|
||||||
// Local exit plume: short enough to remain visible in the canvas gutter.
|
// Local exit plume: short enough to remain visible in the canvas gutter.
|
||||||
const at={x:edge.x+dx*size*.48*ease,y:edge.y+dy*size*.48*ease};
|
const at={x:edge.x+dx*size*.48*ease,y:edge.y+dy*size*.48*ease};
|
||||||
for(let i=3;i>=1;i--){
|
for(let i=3;i>=1;i--){
|
||||||
@@ -154,3 +246,28 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Macaron exit: the token reaches its port, swells like a bubble, pops, and drifts off as smaller bubbles with
|
||||||
|
// highlights that fade out. Circles with alpha only; every bubble's offsets are fixed so all frames agree.
|
||||||
|
function bubbleDissolve(p,edge,angle,size,tile,color,t){
|
||||||
|
const dx=Math.cos(angle),dy=Math.sin(angle),alphaHex=v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0'),base=color.slice(0,7);
|
||||||
|
const reach=smoothMotion(Math.min(1,t/.38)),at={x:edge.x+dx*size*.22*reach,y:edge.y+dy*size*.22*reach};
|
||||||
|
const swell=t<.38?1+.14*smoothMotion(t/.38):Math.max(0,1.14*(1-(t-.38)/.10));
|
||||||
|
if(swell>0)drawTile(p,at.x,at.y,size,tile,angle,swell);
|
||||||
|
const pop=(t-.36)/.30;
|
||||||
|
if(pop>0&&pop<1){const r=size*(.5+.9*(1-Math.pow(1-pop,3))),ring=Array.from({length:36},(_,i)=>({x:at.x+Math.cos(i/36*Math.PI*2)*r,y:at.y+Math.sin(i/36*Math.PI*2)*r}));p.line([...ring,ring[0]],tint(base,.45)+alphaHex(.8*(1-pop)),Math.max(.6,size*.09*(1-pop)));}
|
||||||
|
// Ten bubbles leave the popped face together: three large, the rest small, spread across the face and drifting
|
||||||
|
// outward past the port, upward, with a slow wobble, so the cloud stays on screen long enough to read.
|
||||||
|
for(let i=0;i<10;i++){
|
||||||
|
const s1=(i*7919%97)/97,s2=(i*104729%89)/89,s3=(i*15485863%83)/83;
|
||||||
|
const born=.34+.12*s1,life=.48+.18*s2,u=(t-born)/life;
|
||||||
|
if(u<=0||u>=1)continue;
|
||||||
|
const side=(s3-.5)*2.4,along=(s1-.5)*1.4,r0=size*(i<3?.22+.12*s2:.11+.12*s2);
|
||||||
|
const drift=size*(.05+(.35+.5*s2)*(1-Math.pow(1-u,2))),wobble=Math.sin(u*Math.PI*1.5+s1*6.28)*size*.12;
|
||||||
|
const bx=at.x+dx*(along*size*.35+drift)-dy*(side*size*.5+wobble),by=at.y+dy*(along*size*.35+drift)+dx*(side*size*.5+wobble)-size*.42*u;
|
||||||
|
const grow=Math.min(1,u/.18),fade=u>.6?1-(u-.6)/.4:1,r=r0*grow*fade;
|
||||||
|
if(r<=0)continue;
|
||||||
|
p.circle(bx,by,r,tint(base,.28)+alphaHex(.92*fade));
|
||||||
|
p.circle(bx-r*.32,by-r*.34,r*.28,'#FFFFFF'+alphaHex(.9*fade));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-3
@@ -4,12 +4,11 @@ import { itemStudy } from './item-study.js';
|
|||||||
import { attachBoardViewport } from './board-viewport.js';
|
import { attachBoardViewport } from './board-viewport.js';
|
||||||
import { createStudyLevel } from './study-levels.js';
|
import { createStudyLevel } from './study-levels.js';
|
||||||
import { TapQueue } from './tap-queue.js';
|
import { TapQueue } from './tap-queue.js';
|
||||||
import { exitTiming } from './exit-animation.js';
|
|
||||||
import { LEVEL_THEMES, PIGMENTS, MOTION, themeWithColors } from './design-tokens.js';
|
import { LEVEL_THEMES, PIGMENTS, MOTION, themeWithColors } from './design-tokens.js';
|
||||||
import { LEVELS } from './levels.js';
|
import { LEVELS } from './levels.js';
|
||||||
import { Game, available, solveRotations, trace } from './core.js';
|
import { Game, available, solveRotations, trace } from './core.js';
|
||||||
import { layout, hitCell } from './geometry.js';
|
import { layout, hitCell } from './geometry.js';
|
||||||
import { CanvasPainter, drawScene, drawTile } from './renderer.js';
|
import { CanvasPainter, drawScene, drawTile, exitDuration } from './renderer.js';
|
||||||
import { ICONS, ICON_SIZE, ICON_ATLAS } from './ui-skin.js';
|
import { ICONS, ICON_SIZE, ICON_ATLAS } from './ui-skin.js';
|
||||||
import { TILE_ATLAS } from './sprites.js';
|
import { TILE_ATLAS } from './sprites.js';
|
||||||
const taps=new TapQueue();
|
const taps=new TapQueue();
|
||||||
@@ -74,7 +73,7 @@ function draw(now=performance.now()){
|
|||||||
function reset(){taps.clear();animation=null;rotation=null;afterRotation=null;hint=null;cancelAnimationFrame(frame);game=new Game(studyLevel);$('phone-status').textContent='点击棋子,沿轨道出发';$('motion-status').textContent='可反复播放,也可直接操作棋盘。';controls();draw();}
|
function reset(){taps.clear();animation=null;rotation=null;afterRotation=null;hint=null;cancelAnimationFrame(frame);game=new Game(studyLevel);$('phone-status').textContent='点击棋子,沿轨道出发';$('motion-status').textContent='可反复播放,也可直接操作棋盘。';controls();draw();}
|
||||||
function move(tile){if(animation||rotation){if(taps.add(tile.id,animation?.route.tile.id))draw();return;}hint=null;const route=game.tap(tile.id);if(!['exit','blocked','loop'].includes(route.status))return;
|
function move(tile){if(animation||rotation){if(taps.add(tile.id,animation?.route.tile.id))draw();return;}hint=null;const route=game.tap(tile.id);if(!['exit','blocked','loop'].includes(route.status))return;
|
||||||
if(route.status!=='exit')taps.clear();
|
if(route.status!=='exit')taps.clear();
|
||||||
const base=route.status==='exit'?exitTiming(route).duration:MOTION.collisionMs;
|
const base=route.status==='exit'?exitDuration(route,theme.artStyle):MOTION.collisionMs;
|
||||||
animation={route,start:performance.now(),duration:reduced?150:base*($('slow').checked?2.5:1)};
|
animation={route,start:performance.now(),duration:reduced?150:base*($('slow').checked?2.5:1)};
|
||||||
$('phone-status').textContent=route.status==='exit'?'沿着轨道,找到出口…':'接触后沿原路返回';$('motion-status').textContent=route.status==='exit'?'正在播放:滑行 → 出口收束 → 六角余韵':'正在展示碰撞反馈';controls();draw();
|
$('phone-status').textContent=route.status==='exit'?'沿着轨道,找到出口…':'接触后沿原路返回';$('motion-status').textContent=route.status==='exit'?'正在播放:滑行 → 出口收束 → 六角余韵':'正在展示碰撞反馈';controls();draw();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
// Shared clean silhouette: sample color inside the PNG; never sample its noisy cutout edge.
|
// Shared clean silhouette: sample color inside the PNG; never sample its noisy cutout edge.
|
||||||
export function tileSample(f){return {x:Math.round(f.x+f.w*.4),y:Math.round(f.y+f.h*.4),w:Math.round(f.w*.2),h:Math.round(f.h*.2)};}
|
export function tileSample(f){return {x:Math.round(f.x+f.w*.4),y:Math.round(f.y+f.h*.4),w:Math.round(f.w*.2),h:Math.round(f.h*.2)};}
|
||||||
export function roundedHex(x,y,r){
|
export function roundedHex(x,y,r,rounding=.075){
|
||||||
const vertices=Array.from({length:6},(_,i)=>({x:x+Math.cos(i*Math.PI/3)*r,y:y+Math.sin(i*Math.PI/3)*r})),out=[];
|
const vertices=Array.from({length:6},(_,i)=>({x:x+Math.cos(i*Math.PI/3)*r,y:y+Math.sin(i*Math.PI/3)*r})),out=[];
|
||||||
for(let i=0;i<6;i++){
|
for(let i=0;i<6;i++){
|
||||||
const v=vertices[i],prev=vertices[(i+5)%6],next=vertices[(i+1)%6],a={x:v.x+(prev.x-v.x)*.075,y:v.y+(prev.y-v.y)*.075},b={x:v.x+(next.x-v.x)*.075,y:v.y+(next.y-v.y)*.075};
|
const v=vertices[i],prev=vertices[(i+5)%6],next=vertices[(i+1)%6],a={x:v.x+(prev.x-v.x)*rounding,y:v.y+(prev.y-v.y)*rounding},b={x:v.x+(next.x-v.x)*rounding,y:v.y+(next.y-v.y)*rounding};
|
||||||
for(let j=0;j<=6;j++){const t=j/6,u=1-t;out.push({x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y});}
|
for(let j=0;j<=6;j++){const t=j/6,u=1-t;out.push({x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y});}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ export function tint(hex,amount){return '#'+hex.slice(1).match(/../g).map(v=>Mat
|
|||||||
export function drawStyledToken(p,x,y,r,tile,angle){
|
export function drawStyledToken(p,x,y,r,tile,angle){
|
||||||
const fill=tile.fill,ink='#263A3A';
|
const fill=tile.fill,ink='#263A3A';
|
||||||
if(!fill||!tile.artStyle||tile.artStyle==='classic')return false;
|
if(!fill||!tile.artStyle||tile.artStyle==='classic')return false;
|
||||||
|
if(tile.artStyle==='macaron'){
|
||||||
|
// Macaron: a flat gummy tile with a white sticker rim and one chunky white line arrow (a white mark on candy).
|
||||||
|
const face=roundedHex(x,y,r,.16),white=tile.arrow||'#FFFFFF',w=Math.max(1.5,r*.20);
|
||||||
|
p.polygon(face,fill);p.line([...face,face[0]],'#FFFFFF',Math.max(1,r*.10));
|
||||||
|
const rot=pts=>pts.map(([u,v])=>({x:x+r*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+r*(u*Math.sin(angle)+v*Math.cos(angle))}));
|
||||||
|
p.line(rot([[-.34,0],[.30,0]]),white,w);p.line(rot([[.02,-.30],[.30,0],[.02,.30]]),white,w);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if(tile.artStyle==='signpost'){
|
||||||
|
// Signpost: inset face with a soft ink outline and one white wayfinding arrow for every pigment.
|
||||||
|
const s=r/.90,face=roundedHex(x,y,r);
|
||||||
|
p.polygon(face,fill);p.line([...face,face[0]],'#2E425661',Math.max(1.5,s*.05));
|
||||||
|
const a=r*.66,shape=[[-.62,-.2],[.06,-.2],[.06,-.52],[.64,0],[.06,.52],[.06,.2],[-.62,.2]];
|
||||||
|
p.polygon(shape.map(([u,v])=>({x:x+a*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+a*(u*Math.sin(angle)+v*Math.cos(angle))})),tile.arrow||'#FFFFFF');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if(tile.artStyle==='paper'){
|
if(tile.artStyle==='paper'){
|
||||||
const face=hexPoints(x,y,r),unit=r/.88;
|
const face=hexPoints(x,y,r),unit=r/.88;
|
||||||
p.polygon(face,fill);p.line([...face,face[0]],'#FFFFFFCC',unit*.035);
|
p.polygon(face,fill);p.line([...face,face[0]],'#FFFFFFCC',unit*.035);
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ test('paper study preserves the variants reference: pale thin rails and outlined
|
|||||||
assert.ok(rails.every(c=>Math.abs(c.width-40*.055)<1e-9));
|
assert.ok(rails.every(c=>Math.abs(c.width-40*.055)<1e-9));
|
||||||
assert.ok(!commands.some(c=>c.kind==='line'&&c.color===theme.materials.trackHalo),'no thick rail halo');
|
assert.ok(!commands.some(c=>c.kind==='line'&&c.color===theme.materials.trackHalo),'no thick rail halo');
|
||||||
});
|
});
|
||||||
test('each of five art directions has five readable themes and independent token colors',()=>{
|
test('each of seven art directions has five readable themes and independent token colors',()=>{
|
||||||
assert.equal(ART_DIRECTIONS.length,5);assert.equal(new Set(ART_DIRECTIONS.map(d=>d.id)).size,5);
|
assert.equal(ART_DIRECTIONS.length,7);assert.equal(new Set(ART_DIRECTIONS.map(d=>d.id)).size,7);
|
||||||
for(const d of ART_DIRECTIONS){
|
for(const d of ART_DIRECTIONS){
|
||||||
assert.deepEqual(d.themes.map(t=>t.label),['轻松','进阶','烧脑','挑战','每周挑战']);
|
assert.deepEqual(d.themes.map(t=>t.label),['轻松','进阶','烧脑','挑战','每周挑战']);
|
||||||
for(const t of d.themes){assert.ok(contrast(t.background,t.ink)>=4.5);assert.equal(directionTheme(d,t.id).artStyle,d.style);}
|
for(const t of d.themes){assert.ok(contrast(t.background,t.ink)>=4.5);assert.equal(directionTheme(d,t.id).artStyle,d.style);}
|
||||||
@@ -42,7 +42,7 @@ test('art styles change actual geometry without changing level state, including
|
|||||||
drawTile(p,50,50,40,t);signatures.push(JSON.stringify(commands));
|
drawTile(p,50,50,40,t);signatures.push(JSON.stringify(commands));
|
||||||
drawScene(p,level,level.tiles,view,{theme});drawExit(p,route,view,.4,t);drawExit(p,route,view,.99,t);
|
drawScene(p,level,level.tiles,view,{theme});drawExit(p,route,view,.4,t);drawExit(p,route,view,.99,t);
|
||||||
}
|
}
|
||||||
assert.equal(new Set(signatures).size,5);assert.equal(JSON.stringify(level),copy);
|
assert.equal(new Set(signatures).size,7);assert.equal(JSON.stringify(level),copy);
|
||||||
});
|
});
|
||||||
test('vivid worlds retain strong chroma and each new theme has its own seven-color mix',()=>{
|
test('vivid worlds retain strong chroma and each new theme has its own seven-color mix',()=>{
|
||||||
const vivid=artDirection('vivid');
|
const vivid=artDirection('vivid');
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ const storage=entries=>{
|
|||||||
return {getItem:key=>items.get(key)??null,setItem:(key,value)=>items.set(key,value)};
|
return {getItem:key=>items.get(key)??null,setItem:(key,value)=>items.set(key,value)};
|
||||||
};
|
};
|
||||||
|
|
||||||
test('five selectable editions retain their own level counts, order and chapters',()=>{
|
test('every selectable edition retains its own level count, order and chapters',()=>{
|
||||||
|
// Historical editions keep their exact shape; new ones are appended, because
|
||||||
|
// other tests address the originals by position.
|
||||||
assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[
|
assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[
|
||||||
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],['v0.4',20,5,4],['v0.5',1000,50,20],
|
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],['v0.4',20,5,4],['v0.5',1000,50,20],['c50',50,10,5],
|
||||||
]);
|
]);
|
||||||
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
|
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
|
||||||
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');
|
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');
|
||||||
|
|||||||
Reference in New Issue
Block a user