feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { itemStudy, itemIcon } from '../src/item-study.js';
|
||||
import { ICON_ATLAS } from '../src/ui-skin.js';
|
||||
import { ART_DIRECTIONS } from '../src/art-directions.js';
|
||||
const out=new URL('../design/directions/',import.meta.url);await mkdir(out,{recursive:true});
|
||||
for(const d of ART_DIRECTIONS){
|
||||
const html=`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${d.name} · HEX TRAILS 设计稿</title><link rel="stylesheet" href="../showcase.css"><link rel="stylesheet" href="../art-directions.css"><link rel="stylesheet" href="../game-tools.css"></head><body class="sheet-page" data-design="${d.id}" style="--paper:${d.paper};--accent:${d.accent};--bg:${d.themes[0].background}"><main class="sheet-main"><nav class="sheet-nav"><a href="../../design-showcase.html?design=${d.id}">← 回到设计审核</a><span>HEX TRAILS / ART DIRECTION ${d.code}</span></nav><header class="sheet-hero"><div><span class="eyebrow">${d.en}</span><h1>${d.name}</h1><p>${d.description}</p></div><canvas id="sheet-tokens" class="sheet-token-row" width="720" height="270" aria-label="${d.name}棋子造型设计"></canvas></header><section><div class="section-heading"><div><span class="eyebrow">01 / FIVE WORLDS</span><h2>同一风格,五种主题</h2></div><p>统一的弯曲轨道关卡 · 用实际渲染检查识别度</p></div><div class="sheet-themes">${d.themes.map(t=>`<article class="sheet-theme" style="--bg:${t.background};--ink:${t.ink}"><small>${t.label}</small><h3>${t.name}</h3><canvas data-sheet-theme="${t.id}" width="480" height="520" aria-label="${t.label}主题的弯曲棋盘"></canvas><div class="sheet-swatches">${t.palette.map(i=>`<i style="background:${d.pigments[i].color}" title="${d.pigments[i].color}"></i>`).join('')}</div><small>${t.background}</small></article>`).join('')}</div></section><section class="sheet-components"><div class="sheet-panel"><span class="eyebrow">02 / DESIGN LANGUAGE</span><h2>造型与交互规则</h2><ul>${d.principles.map(p=>`<li>${p}</li>`).join('')}<li>所有棋子保留独立方向箭头,颜色和表情不改变规则。</li><li>离场与碰撞共用游戏动画,切换设计保持路线与时长一致。</li></ul><p class="sheet-caption">这是一套可与其他方案同时保留的候选设计。到审核页勾选保留、记录修改意见;不会替换你的其他选择。</p><a class="sheet-link" href="../../design-showcase.html?design=${d.id}">审核与体验这套设计 ↗</a></div><div class="sheet-panel"><span class="eyebrow">03 / COMPONENT STUDY</span><h2>控件与奖励</h2><div class="component-preview">${itemStudy(name=>itemIcon(name,'../../'+ICON_ATLAS))}<div class="sample-modal"><div class="stars" aria-label="三颗金色星星">★ ★ ★</div><h3>漂亮,全部过关!</h3><p>每一次转弯,都找到出口。</p><a class="sheet-link" href="../../design-showcase.html?design=${d.id}">继续旅程 →</a></div></div><p class="sheet-caption">底部道具独立于顶部导航。棋盘在通知与旋转区之间垂直居中;可操作棋盘在设计审核页。</p></div></section></main><script type="module" src="../../src/direction-sheet.js"></script></body></html>`;
|
||||
await writeFile(new URL(`${d.id}.html`,out),html);
|
||||
await writeFile(new URL(`${d.id}.md`,out),`# ${d.name}\n\n${d.description}\n\n## 设计规则\n\n${d.principles.map(p=>`- ${p}`).join('\n')}\n\n## 五种主题\n\n| 难度 | 名称 | 环境色 |\n| --- | --- | --- |\n${d.themes.map(t=>`| ${t.label} | ${t.name} | ${t.background} |`).join('\n')}\n\n## 游戏道具与布局\n\n- 撤销、重来、提示使用独立彩色底座,64–68px,图标 32px,标签 13px。顶部导航保持紧凑。道具使用纯色平面,不使用阴影、立体底边或渐变高光。\n- 右上角预留次数或“▶ AD”角标。设计稿展示“撤销 3 次”和“广告获取提示”,仅为样式,不启用真实库存或广告逻辑。\n- 棋盘在通知区与独立旋转区之间垂直居中。旋转区和道具区占据真实布局行,不覆盖棋盘。\n\n此方案使用独立的棋子造型、轨道材质和控件样式,共用现有弯曲轨道玩法。审核页支持同时保留多套方案。\n`);
|
||||
}
|
||||
console.log('Art direction sheets built: 4 designs × 5 themes.');
|
||||
@@ -1,3 +1,4 @@
|
||||
import { itemRow, itemStudy } from '../src/item-study.js';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { THEME, CONTRAST_PAIRS, contrast, TYPOGRAPHY, MOTION, RADII } from '../src/design-tokens.js';
|
||||
import { ICONS, ICON_SIZE, ICON_ATLAS } from '../src/ui-skin.js';
|
||||
@@ -52,6 +53,7 @@ const tiles=sceneSVG(p=>[0,1,2,3].forEach((color,i)=>drawTile(p,95+(i%2)*170,95+
|
||||
const replacements={
|
||||
TOKENS:Object.entries(THEME).map(([k,v])=>`--${k}:${v};`).join('')+`--pressMs:${MOTION.pressMs}ms;--modalMs:${MOTION.modalMs}ms;--starOne:${MOTION.starStartMs}ms;--starTwo:${MOTION.starStartMs+MOTION.starGapMs}ms;--starThree:${MOTION.starStartMs+MOTION.starGapMs*2}ms;--controlRadius:${RADII.control}px;--dialogRadius:${RADII.dialog}px;`,ICON_ATLAS:dataImage(ICON_ATLAS),ICON_CSS:iconCSS,
|
||||
TILE_IMAGE:`<svg width="0" height="0" aria-hidden="true" class="svg-defs"><defs><image id="tile-atlas-image" width="1293" height="1216" href="${dataImage(TILE_ATLAS)}"/></defs></svg>`,
|
||||
GAME_TOOLS_CSS:read('design/game-tools.css'),GAME_ITEMS:itemStudy(icon),GAME_ITEMS_ROW:itemRow(icon),
|
||||
BOARD:board,TILES:tiles,MOVES:level.moveLimit,SWATCHES:swatches,PAIR_ROWS:pairRows,
|
||||
ICONS:Object.keys(ICONS).map(name=>`<div class="icon-item"><span class="icon-ground">${icon(name)}</span><b>${iconNames[name]}</b><code>${name}</code></div>`).join(''),
|
||||
SPEC:renderMarkdown(markdown),PAYLOAD:JSON.stringify({markdown,theme:THEME,typography:TYPOGRAPHY,motion:MOTION,radii:RADII}).replace(/</g,'\\u003c'),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateExperienceLevel, inspectExperience, pointsInward, countBends } from '../src/experience-generator.js';
|
||||
import { analyze, trace, rotateBoard, validateLevel, available } from '../src/core.js';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const manifest = JSON.parse(await readFile(new URL('levels/v0.4-seeds.json', root), 'utf8'));
|
||||
assert.equal(manifest.seeds.length, 20);
|
||||
assert.equal(new Set(manifest.seeds.map(p => p.seed)).size, 20);
|
||||
const levels = [], rows = [];
|
||||
const kinds = { tour: '长路巡游', contrast: '轻巧反差', gate: '清挡放行', route: '旋转换路', return: '往返换路', stages: '分段释放', finale: '长路收束' };
|
||||
for (const [index, recipe] of manifest.seeds.entries()) {
|
||||
const label = `v0.4 ${index + 1} ${recipe.name}`;
|
||||
assert.equal(recipe.level, index + 1, label);
|
||||
const chapter = Math.floor(index / manifest.campaign.chapterSize);
|
||||
const level = {
|
||||
...generateExperienceLevel(recipe.seed, recipe.options),
|
||||
id: `experience-v04-${String(index + 1).padStart(2, '0')}`,
|
||||
name: recipe.name, chapter: chapter + 1, chapterName: manifest.campaign.chapters[chapter],
|
||||
beat: kinds[recipe.kind], lesson: recipe.lesson, designIntent: recipe.designIntent,
|
||||
experience: { kind: recipe.kind, feature: recipe.feature, actions: recipe.experienceActions },
|
||||
};
|
||||
assert.deepEqual(validateLevel(level), [], label);
|
||||
assert.equal(level.tiles.length, recipe.options.target, label);
|
||||
const a = analyze(level), reference = inspectExperience(level, a.actions), experience = inspectExperience(level, recipe.experienceActions);
|
||||
assert.ok(a.solvable && !a.exhausted && reference.won && experience.won, label);
|
||||
assert.equal(a.rotations, recipe.targets.rotations, label);
|
||||
for (const q of [reference, experience]) {
|
||||
assert.ok(q.maxBends >= recipe.targets.minMaxBends && q.longPaths >= recipe.targets.minLongPaths, label);
|
||||
assert.ok(q.maxDryRotations <= 1, label);
|
||||
}
|
||||
assert.ok(reference.hidden.length <= recipe.targets.maxHidden, label);
|
||||
assert.ok(experience.hidden.length <= recipe.targets.maxExperienceHidden, label);
|
||||
if (recipe.kind === 'tour') assert.equal(a.free, level.tiles.length, label);
|
||||
if (recipe.kind === 'contrast') {
|
||||
const feature = recipe.feature, tile = level.tiles.find(t => t.id === feature.id);
|
||||
const before = trace(level, tile), after = trace(rotateBoard(level, feature.direction), tile);
|
||||
assert.ok(pointsInward(tile), label);
|
||||
assert.equal(before.status, 'blocked', label);
|
||||
assert.ok(trace(level, tile, level.tiles, true).blockers.includes(before.blocker), label);
|
||||
assert.equal(after.status, 'exit', label);
|
||||
assert.equal(countBends(after), feature.bends, label);
|
||||
assert.deepEqual(recipe.experienceActions.slice(0, 2), [{ type: 'rotate', direction: feature.direction }, { type: 'tap', id: feature.id }], label);
|
||||
}
|
||||
if (recipe.kind === 'gate') assert.ok(reference.unlocks.some(u => u.id === recipe.feature.id && u.removed === recipe.feature.removed && u.onRoute && u.bends >= 8), label);
|
||||
if (recipe.kind === 'return') assert.ok(reference.turns.length === 2 && reference.turns[0].direction === -reference.turns[1].direction, label);
|
||||
if (recipe.kind === 'finale') assert.ok(reference.paths.slice(-5).filter(p => p.bends >= 10).length >= 3, label);
|
||||
const orientations = Array.from({ length: 6 }, (_, turns) => {
|
||||
const board = rotateBoard(level, turns), routes = level.tiles.map(t => trace(board, t));
|
||||
return {
|
||||
turns, free: available(board).length,
|
||||
independentLoops: level.tiles.filter(t => trace(board, t, level.tiles, true).status === 'loop').map(t => t.id),
|
||||
offRouteBlockers: routes.flatMap((r, i) => r.status === 'blocked' && !trace(board, level.tiles[i], level.tiles, true).blockers.includes(r.blocker) ? [{ id: level.tiles[i].id, blocker: r.blocker }] : []),
|
||||
};
|
||||
});
|
||||
level.requiresRotation = a.rotations > 0;
|
||||
levels.push(level);
|
||||
rows.push({ level: index + 1, name: level.name, kind: recipe.kind, cells: level.cells.length, tiles: level.tiles.length, free: a.free, minimumRotations: a.rotations, structuralScore: a.score, reference, experience, orientations });
|
||||
}
|
||||
const referencePaths = rows.flatMap(r => r.reference.paths), curved = referencePaths.filter(p => p.bends > 0);
|
||||
const summary = {
|
||||
levels: levels.length, removals: referencePaths.length, curvedRemovals: curved.length,
|
||||
curvedRatio: curved.length / referencePaths.length,
|
||||
bendsPerCurvedPath: curved.reduce((s, p) => s + p.bends, 0) / curved.length,
|
||||
maxBends: Math.max(...referencePaths.map(p => p.bends)),
|
||||
longPaths: referencePaths.filter(p => p.bends >= 6).length,
|
||||
rotationRequired: rows.filter(r => r.minimumRotations > 0).length,
|
||||
immediateContrastLevels: rows.filter(r => r.kind === 'contrast').map(r => r.level),
|
||||
};
|
||||
const write = (path, text) => writeFile(new URL(path, root), text);
|
||||
await write('levels/v0.4.json', JSON.stringify(levels, null, 2) + '\n');
|
||||
await write('src/levels-v04.js', '// Generated by npm run levels:v04. Curated experience recipes; do not difficulty-sort.\nexport const EXPERIENCE_CAMPAIGN = ' + JSON.stringify(manifest.campaign) + ';\nexport const EXPERIENCE_LEVELS = ' + JSON.stringify(levels) + ';\n');
|
||||
await write('levels/v0.4-analysis.json', JSON.stringify({ campaign: manifest.campaign, note: 'Reference and authored experience plans are complete replays. Six-orientation checks use the initial piece set. These are structural observations, not exhaustive exploration or player enjoyment data. Long paths in this report mean at least six bends; this is a v0.4 sample-selection band, not a universal design rule.', summary, levels: rows }, null, 2) + '\n');
|
||||
let md = '# v0.4 · 长路与惊喜:20 关体验与回放\n\n依据 [新版设计准则](level-design-principles-v2.md),按 4 组 × 5 关编排。制作来源为 [种子与制作卡](../levels/v0.4-seeds.json),运行 `npm run levels:v04` 同步布局、模块和本报告。全部为新种子生成与筛选的布局,没有重用旧版关卡。\n\n';
|
||||
md += `20 关参考解共 ${summary.removals} 次成功消除,其中 ${summary.longPaths} 条路径至少转弯 6 次;每条弯轨路径平均 ${summary.bendsPerCurvedPath.toFixed(2)} 弯,最长 ${summary.maxBends} 弯。${summary.rotationRequired} 关必须旋转,${summary.immediateContrastLevels.length} 关存在开局朝内棋子一次旋转后立即可点击离场的完整体验解。\n\n`;
|
||||
md += '“长路”在本批统计中指至少 6 弯,仅用于样关比较。下表路径与旋转来自最少旋转参考解;反差关另有先转盘、再点击目标的完整体验解。结构分会给长路加分,不应把直接可清空的巡游关误判成高推理负担。没有真人用时或满意度数据。\n\n';
|
||||
md += '| 关 | 名称 | 体验 | 格 / 棋子 | 开局可出 | 最少旋转 | 最长弯数 | 长路条数 | 参考 / 体验格外阻挡样本 |\n| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |\n';
|
||||
md += rows.map(r => `| ${r.level} | ${r.name} | ${kinds[r.kind]} | ${r.cells} / ${r.tiles} | ${r.free} | ${r.minimumRotations} | ${r.reference.maxBends} | ${r.reference.longPaths} | ${r.reference.hidden.length} / ${r.experience.hidden.length} |`).join('\n');
|
||||
md += '\n\n## 验收边界\n\n两套完整回放均可通关,相邻消除之间最多一次旋转;巡游关开局全可出;反差关目标确实朝内,开局被路线内棋子阻挡,左右其中一次旋转就获得无碰撞出口;清挡关有短路线释放至少 8 弯路径;往返关包含两个相反方向的必要旋转。\n\n本版恢复多阶段与长绕行,没有沿用 v0.3 全六朝向无闭环、参考状态格外阻挡为零的统一筛选。表中的格外阻挡样本按“决策状态 × 棋子”计,同一阻挡可重复出现;实际阻挡对象与接触反馈继续使用现有引擎。六朝向开局的闭环和擦碰明细保存在 [完整分析](../levels/v0.4-analysis.json),不能宣称所有探索路径都无闭环、无擦碰。替代顺序的可解性另由测试检查,真人可读性与完整动画手感仍需试玩。\n\n## 逐关制作卡\n\n';
|
||||
for (const [i, r] of rows.entries()) {
|
||||
const recipe = manifest.seeds[i];
|
||||
const longest = r.experience.paths.reduce((a, b) => a.bends > b.bends ? a : b);
|
||||
md += `### ${String(i + 1).padStart(2, '0')} · ${r.name}\n\n${recipe.designIntent}\n\n- 提示:${recipe.lesson}\n- 关键体验路径:\`${longest.id}\`,${longest.bends} 弯 / ${longest.cells} 格步骤。\n`;
|
||||
if (r.kind === 'contrast') md += `- 反差操作:开局${recipe.feature.direction === 1 ? '右' : '左'}转一次,再点 \`${recipe.feature.id}\`(${recipe.feature.bends} 弯);箭头确实指向更内侧的六边形环。\n`;
|
||||
if (r.kind === 'gate') md += `- 清挡证据:参考解第 ${recipe.feature.action + 1} 次操作移走 \`${recipe.feature.removed}\`,为 \`${recipe.feature.id}\` 打开 ${recipe.feature.bends} 弯路径。\n`;
|
||||
if (r.reference.turns.length) md += `- 参考换路阶段:${r.reference.turns.map(t => `剩 ${t.remaining} 枚时${t.direction === 1 ? '右' : '左'}转`).join(' → ')}。\n`;
|
||||
md += `- 完整体验动作:${recipe.experienceActions.map(a => a.type === 'tap' ? a.id : a.direction === 1 ? '右转' : '左转').join(' → ')}。\n\n`;
|
||||
}
|
||||
await write('docs/v0.4-experience-review.md', md);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
@@ -0,0 +1,136 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { productionSlot, PRODUCTION_CAMPAIGN, PRODUCTION_PLAN_VERSION } from '../src/production-plan.js';
|
||||
import { constructProductionCandidate, inspectCandidate } from '../src/production-generator.js';
|
||||
import { assessLevel, canonicalLevelSignature, experienceSignature } from '../src/campaign-evidence.js';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const args = process.argv.slice(2);
|
||||
const arg = (name, fallback) => { const value = args.find(a => a.startsWith(`${name}=`)); return value ? value.split('=')[1] : fallback; };
|
||||
const count = Number(arg('--count', 1000)), choices = Number(arg('--choices', 3));
|
||||
if (!Number.isInteger(count) || count < 1 || count > 1000 || !Number.isInteger(choices) || choices < 1 || choices > 8) throw new Error('Invalid count or choices');
|
||||
const publish = args.includes('--publish');
|
||||
if (publish && count !== 1000) throw new Error('Only a fully validated 1000-level edition may be published');
|
||||
const checkpointUrl = new URL('tmp/production-v05/checkpoint.json', root);
|
||||
await mkdir(new URL('tmp/production-v05/', root), { recursive: true });
|
||||
const digest = text => createHash('sha256').update(text).digest('hex');
|
||||
const sourceFiles = ['scripts/build-production-levels.mjs', 'src/production-plan.js', 'src/production-generator.js', 'src/route-grammar.js', 'src/stage-seed.js', 'src/flower-study.js', 'src/experience-generator.js', 'src/campaign-evidence.js', 'src/core.js', 'src/path-math.js', 'src/geometry.js'];
|
||||
const sourceHashes = Object.fromEntries(await Promise.all(sourceFiles.map(async path => [path, digest(await readFile(new URL(path, root)))])));
|
||||
const pipelineHash = digest(JSON.stringify(sourceHashes));
|
||||
const levels = [], rows = [], recipes = [], signatures = new Set(), rejections = {};
|
||||
const reject = reason => { rejections[reason] = (rejections[reason] || 0) + 1; };
|
||||
if (args.includes('--resume')) {
|
||||
const saved = JSON.parse(await readFile(checkpointUrl, 'utf8'));
|
||||
if (saved.planVersion !== PRODUCTION_PLAN_VERSION) throw new Error('Checkpoint plan version differs');
|
||||
if (saved.pipelineHash !== pipelineHash) throw new Error('Generator/rule source changed; start a fresh batch instead of mixing checkpoints');
|
||||
if (saved.choices !== choices) throw new Error('Candidate count differs; resume with the same --choices value');
|
||||
levels.push(...saved.levels.slice(0, count)); rows.push(...saved.rows.slice(0, count)); recipes.push(...saved.recipes.slice(0, count));
|
||||
Object.assign(rejections, saved.rejections);
|
||||
for (const row of rows) signatures.add(row.layoutHash);
|
||||
}
|
||||
const started = Date.now();
|
||||
const checkpoint = async () => {
|
||||
const temporary = new URL('tmp/production-v05/checkpoint.next.json', root);
|
||||
await writeFile(temporary, JSON.stringify({ planVersion: PRODUCTION_PLAN_VERSION, pipelineHash, sourceHashes, choices, levels, rows, recipes, rejections }));
|
||||
await rename(temporary, checkpointUrl);
|
||||
};
|
||||
|
||||
for (let index = levels.length; index < count; index++) {
|
||||
const slot = productionSlot(index), shortlist = [];
|
||||
let attempts = 0;
|
||||
for (; attempts < 2400 && shortlist.length < choices; attempts++) {
|
||||
const candidate = constructProductionCandidate(slot, attempts);
|
||||
if (candidate.rejected) { reject(candidate.rejected); continue; }
|
||||
const inspected = inspectCandidate(candidate.level, slot);
|
||||
if (inspected.rejected) { reject(inspected.rejected); continue; }
|
||||
const hash = digest(canonicalLevelSignature(candidate.level));
|
||||
if (signatures.has(hash) || shortlist.some(c => c.hash === hash)) { reject('duplicate-layout'); continue; }
|
||||
const evidence = assessLevel(candidate.level, { actions: candidate.level.experience.actions, policyRuns: 6 });
|
||||
if (!evidence.solvable || evidence.exhausted || !evidence.reference.won || !evidence.solverReference.won || evidence.policies.some(p => !p.won)) { reject('policy-or-replay-failed'); continue; }
|
||||
const corePolicies = evidence.policies.slice(0, 6);
|
||||
const coreLongSeen = corePolicies.filter(p => p.longPaths > 0).length;
|
||||
if (slot.number > 2 && coreLongSeen < 5) { reject('natural-play-misses-long-route'); continue; }
|
||||
const heroId = candidate.level.experience.feature.id;
|
||||
const heroContrasts = corePolicies.filter(p => p.fulfilledReveals.some(e => e.id === heroId && e.bends >= slot.minHeroBends)).length;
|
||||
const heroGates = corePolicies.filter(p => p.fulfilledUnlocks.some(e => e.id === heroId && e.onRoute && e.bends >= slot.minHeroBends)).length;
|
||||
if (slot.kind === 'contrast' && heroContrasts < (slot.number <= 30 ? 4 : 3)) { reject('natural-play-bypasses-contrast'); continue; }
|
||||
if (slot.kind === 'gate' && heroGates < 4) { reject('natural-play-bypasses-gate'); continue; }
|
||||
const heroLong = policy => policy.paths.some(path => path.id === heroId && path.bends >= slot.minHeroBends);
|
||||
if (corePolicies.filter(heroLong).length < 4) { reject('core-play-misses-primary-route'); continue; }
|
||||
const extended = assessLevel(candidate.level, { actions: candidate.level.experience.actions, policyRuns: 20 });
|
||||
if (extended.policies.some(policy => !policy.won)) { reject('extended-replay-failed'); continue; }
|
||||
const totalLong = extended.policies.filter(policy => policy.longPaths > 0).length;
|
||||
const totalHero = extended.policies.filter(heroLong).length;
|
||||
if (totalLong < 16 || totalHero < 10) { reject('exploration-misses-primary-reward'); continue; }
|
||||
if (slot.kind === 'gate' && extended.policies.filter(p => p.fulfilledUnlocks.some(e => e.id === heroId && e.onRoute && e.bends >= slot.minHeroBends)).length < 8) { reject('exploration-bypasses-gate'); continue; }
|
||||
if (slot.kind === 'contrast' && extended.policies.filter(p => p.fulfilledReveals.some(e => e.id === heroId && e.bends >= slot.minHeroBends)).length < 6) { reject('exploration-bypasses-contrast'); continue; }
|
||||
const signature = experienceSignature(evidence), recent = rows.slice(-8);
|
||||
const repetition = recent.filter(r => r.experienceSignature === signature).length;
|
||||
// Compare candidates inside the requested family/intent only. This score is
|
||||
// a selection aid (clarity and exposure), never a universal difficulty score.
|
||||
const quality = coreLongSeen * 3 + totalLong * .5 + totalHero * .4 + inspected.shapeUsage.ratio * 5
|
||||
- evidence.difficulty.sweptBlockerPieces * 1.5 - repetition * 4
|
||||
- Math.max(0, evidence.reward.firstLongAction - Math.ceil(candidate.level.tiles.length / 2))
|
||||
- Math.max(0, evidence.reference.maxDryRotations - 1) * 3;
|
||||
shortlist.push({ ...candidate, inspected, evidence: extended, hash, signature, quality });
|
||||
}
|
||||
if (!shortlist.length) { await checkpoint(); throw new Error(`No accepted candidate for slot ${index + 1} ${JSON.stringify(slot)} after ${attempts} attempts; ${JSON.stringify(rejections)}`); }
|
||||
shortlist.sort((a, b) => b.quality - a.quality);
|
||||
const { level, inspected, evidence, hash, signature } = shortlist[0];
|
||||
const paths = evidence.reference.paths;
|
||||
level.production = {
|
||||
...level.production,
|
||||
difficulty: { ...evidence.difficulty, requiredRotations: evidence.difficulty.minimumRotations },
|
||||
reward: { ...evidence.reward, longestSteps: Math.max(...paths.map(p => p.cells)), longestBends: evidence.reference.maxBends, longRoutes: evidence.reference.longPaths },
|
||||
workload: evidence.workload, shapeUsage: inspected.shapeUsage, layoutHash: hash,
|
||||
review: { automatic: 'passed', visual: 'pending', player: 'not-tested' },
|
||||
};
|
||||
level.requiresRotation = inspected.solution.rotations > 0;
|
||||
signatures.add(hash); levels.push(level);
|
||||
recipes.push({ id: level.id, number: index + 1, attempt: level.production.source.attempt, slot, seed: level.seed, layoutHash: hash });
|
||||
rows.push({ id: level.id, level: index + 1, name: level.name, chapter: slot.chapter, kind: slot.kind, family: slot.family,
|
||||
cells: level.cells.length, tiles: level.tiles.length, layoutHash: hash, experienceSignature: signature,
|
||||
difficulty: level.production.difficulty, reward: level.production.reward, workload: evidence.workload,
|
||||
shapeUsage: inspected.shapeUsage, opening: inspected.opening,
|
||||
reference: evidence.solverReference, authored: evidence.reference, policies: evidence.policies,
|
||||
candidates: { attempts, accepted: shortlist.length, selectedAttempt: level.production.source.attempt },
|
||||
review: level.production.review });
|
||||
if (levels.length % 10 === 0 || levels.length === count) {
|
||||
await checkpoint();
|
||||
console.log(`${levels.length}/${count} verified · ${slot.family}/${slot.kind} · ${Math.round((Date.now() - started) / 1000)}s`);
|
||||
}
|
||||
}
|
||||
const tally = values => values.reduce((o, value) => ((o[value] = (o[value] || 0) + 1), o), {});
|
||||
const allPaths = rows.flatMap(r => r.reference.paths), policies = rows.flatMap(r => r.policies);
|
||||
const summary = {
|
||||
levels: levels.length, independentlySolved: rows.filter(r => r.reference.won).length,
|
||||
authoredReplaysPassed: rows.filter(r => r.authored.won).length,
|
||||
policyRuns: policies.length, policyWins: policies.filter(p => p.won).length,
|
||||
policyGroups: { core: 'First six named strategies per level; similar non-rotating policies are not independent player samples', exploration: 'Fourteen additional seeded random strategies per level; all twenty must retain long routes in >=16 and the primary long route in >=10 runs' },
|
||||
uniqueLayouts: signatures.size, families: tally(rows.map(r => r.family)), experiences: tally(rows.map(r => r.kind)),
|
||||
difficultyTiers: tally(rows.map(r => `D${r.difficulty.tier}`)),
|
||||
rotationRequired: rows.filter(r => r.difficulty.requiredRotations > 0).length,
|
||||
maxRequiredRotations: Math.max(...rows.map(r => r.difficulty.requiredRotations)),
|
||||
longRouteLevels: rows.filter(r => r.reference.longPaths > 0).length,
|
||||
longPolicyRuns: policies.filter(p => p.longPaths > 0).length,
|
||||
successfulDepartures: allPaths.length, longDepartures: allPaths.filter(p => p.bends >= 6).length,
|
||||
longestBends: Math.max(...allPaths.map(p => p.bends)), longestSteps: Math.max(...allPaths.map(p => p.cells)),
|
||||
minShapeUsage: Math.min(...rows.map(r => r.shapeUsage.ratio)),
|
||||
minHeroShapeRatio: Math.min(...rows.map(r => r.shapeUsage.heroFeaturedRatio)),
|
||||
byPolicy: Object.fromEntries([...new Set(policies.map(p => p.name))].map(name => {
|
||||
const runs = policies.filter(p => p.name === name);
|
||||
return [name, { runs: runs.length, won: runs.filter(p => p.won).length, longRoute: runs.filter(p => p.longPaths > 0).length }];
|
||||
})),
|
||||
rejections, visualReview: 'pending', playerCalibration: 'not-tested',
|
||||
};
|
||||
await writeFile(new URL('tmp/production-v05/summary.json', root), JSON.stringify(summary, null, 2) + '\n');
|
||||
if (publish) {
|
||||
await mkdir(new URL('levels/v0.5/', root), { recursive: true });
|
||||
await writeFile(new URL('levels/v0.5.json', root), JSON.stringify(levels) + '\n');
|
||||
await writeFile(new URL('levels/v0.5-recipes.json', root), JSON.stringify({ planVersion: PRODUCTION_PLAN_VERSION, pipelineHash, sourceHashes, campaign: PRODUCTION_CAMPAIGN, recipes }, null, 2) + '\n');
|
||||
await writeFile(new URL('levels/v0.5-analysis.json', root), JSON.stringify({ campaign: PRODUCTION_CAMPAIGN,
|
||||
note: 'All metrics are formal-model observations. Six core policies plus fourteen additional seeded random policies are bounded samples, not real-player probabilities. Long routes use six bends as a diagnostic band. Visual review and player calibration are recorded separately.', summary, levels: rows }) + '\n');
|
||||
for (let chapter = 0; chapter < 20; chapter++) await writeFile(new URL(`levels/v0.5/chapter-${String(chapter + 1).padStart(2, '0')}.json`, root), JSON.stringify(levels.slice(chapter * 50, chapter * 50 + 50)) + '\n');
|
||||
await writeFile(new URL('src/levels-v05.js', root), '// Generated by npm run levels:v05. Frozen layouts and authored witnesses.\nexport const PRODUCTION_CAMPAIGN = ' + JSON.stringify(PRODUCTION_CAMPAIGN) + ';\nexport const PRODUCTION_LEVELS = ' + JSON.stringify(levels) + ';\n');
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||
const out=new URL('../dist/',import.meta.url);
|
||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
||||
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/variants'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/car-loop','labs/variants','labs/flower','labs/campaign-review','labs/hex-video/designv2.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
console.log('Static build ready in dist/');
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Game, solveRotations, validateLevel } from '../src/core.js';
|
||||
import { canonicalLevelSignature } from '../src/campaign-evidence.js';
|
||||
import { inspectCandidate } from '../src/production-generator.js';
|
||||
import { countBends } from '../src/experience-generator.js';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const read = async path => JSON.parse(await readFile(new URL(path, root), 'utf8'));
|
||||
const [levels, manifest, analysis] = await Promise.all(['levels/v0.5.json', 'levels/v0.5-recipes.json', 'levels/v0.5-analysis.json'].map(read));
|
||||
const digest = value => createHash('sha256').update(value).digest('hex');
|
||||
assert.equal(levels.length, 1000); assert.equal(manifest.recipes.length, 1000); assert.equal(analysis.levels.length, 1000);
|
||||
for (const [path, hash] of Object.entries(manifest.sourceHashes)) assert.equal(digest(await readFile(new URL(path, root))), hash, `Pipeline changed: ${path}`);
|
||||
const signatures = new Set();
|
||||
let actionCount = 0, replayCount = 0;
|
||||
for (const [index, level] of levels.entries()) {
|
||||
const row = analysis.levels[index], recipe = manifest.recipes[index];
|
||||
assert.equal(level.id, row.id); assert.equal(level.id, recipe.id); assert.equal(row.level, index + 1);
|
||||
assert.equal(row.policies.length, 20, level.id);
|
||||
assert.deepEqual(validateLevel(level), [], level.id);
|
||||
const hash = digest(canonicalLevelSignature(level));
|
||||
assert.equal(hash, row.layoutHash); assert.equal(hash, recipe.layoutHash); assert.ok(!signatures.has(hash), level.id); signatures.add(hash);
|
||||
const solution = solveRotations(level);
|
||||
assert.ok(solution.solvable && !solution.exhausted, level.id);
|
||||
assert.equal(solution.rotations, row.difficulty.requiredRotations, level.id);
|
||||
assert.ok(!inspectCandidate(level, recipe.slot).rejected, level.id);
|
||||
const witnesses = [{ actions: solution.actions, paths: row.reference.paths }, { actions: level.experience.actions, paths: row.authored.paths }, ...row.policies];
|
||||
for (const { actions, paths } of witnesses) {
|
||||
const game = new Game(level);
|
||||
let departed = 0;
|
||||
for (const action of actions) {
|
||||
const result = action.type === 'rotate' ? game.rotate(action.direction) : game.tap(action.id);
|
||||
assert.equal(result.status,
|
||||
action.type === 'rotate' ? 'rotated' : 'exit', `${level.id} action ${JSON.stringify(action)}`);
|
||||
if (action.type === 'tap') {
|
||||
const recorded = paths[departed++];
|
||||
assert.equal(recorded.id, action.id, level.id);
|
||||
assert.equal(recorded.cells, result.steps.length, level.id);
|
||||
assert.equal(recorded.bends, countBends(result), level.id);
|
||||
}
|
||||
actionCount++;
|
||||
}
|
||||
assert.equal(departed, paths.length, level.id);
|
||||
assert.ok(game.won, level.id); assert.equal(game.moves, 0, level.id); replayCount++;
|
||||
}
|
||||
assert.ok(row.policies.filter(p => p.paths.some(path => path.bends >= 6)).length >= 16, level.id);
|
||||
assert.ok(row.policies.filter(p => p.paths.some(path => path.id === level.experience.feature.id && path.bends >= recipe.slot.minHeroBends)).length >= 10, level.id);
|
||||
}
|
||||
const verification = { pipelineHash: manifest.pipelineHash, levels: levels.length, uniqueLayouts: signatures.size, independentlySolved: levels.length, completeReplays: replayCount, successfulActions: actionCount, result: 'passed' };
|
||||
await writeFile(new URL('levels/v0.5-validation.json', root), JSON.stringify(verification, null, 2) + '\n');
|
||||
console.log(JSON.stringify(verification, null, 2));
|
||||
@@ -0,0 +1,96 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const read = async path => JSON.parse(await readFile(new URL(path, root), 'utf8'));
|
||||
const [levels, manifest, report, validation] = await Promise.all(['levels/v0.5.json', 'levels/v0.5-recipes.json', 'levels/v0.5-analysis.json', 'levels/v0.5-validation.json'].map(read));
|
||||
if (levels.length !== 1000 || validation.result !== 'passed' || validation.pipelineHash !== manifest.pipelineHash) throw new Error('Run levels:v05:verify against this exact edition first');
|
||||
const rows = report.levels, sum = report.summary;
|
||||
const count = values => values.reduce((result, value) => ((result[value] = (result[value] || 0) + 1), result), {});
|
||||
const primary = (row, policy) => policy.paths.some(path => path.id === row.reward.heroId && path.bends >= manifest.recipes[row.level - 1].slot.minHeroBends);
|
||||
const minLong = Math.min(...rows.map(row => row.policies.filter(p => p.longPaths > 0).length));
|
||||
const minPrimary = Math.min(...rows.map(row => row.policies.filter(p => primary(row, p)).length));
|
||||
const families = { petals: '花瓣', orbits: '环绕', spiral: '螺旋', switchback: '回折', weave: '编织', crossing: '双叶交叉' };
|
||||
const kinds = { tour: '长路巡游', gate: '清挡放行', contrast: '轻巧反差', turn: '旋转换路', stages: '分段释放' };
|
||||
const chapters = manifest.campaign.chapters.map((name, i) => {
|
||||
const group = rows.slice(i * 50, i * 50 + 50), tiers = count(group.map(row => row.difficulty.tier));
|
||||
return `| ${i + 1} · ${name} | ${i * 50 + 1}–${i * 50 + 50} | ${Array.from({ length: 5 }, (_, tier) => tiers[tier + 1] || 0).join(' / ')} | ${Math.min(...group.map(r => r.tiles))}–${Math.max(...group.map(r => r.tiles))} | ${group.filter(r => r.difficulty.requiredRotations > 0).length} | [总览](v0.5-review/contact-${String(i + 1).padStart(2, '0')}.png) |`;
|
||||
});
|
||||
const policies = Object.entries(sum.byPolicy).map(([name, value]) => `| ${name} | ${value.runs} | ${value.won} | ${value.longRoute} |`);
|
||||
const text = `# v0.5「千段轨迹」1000 关制作报告
|
||||
|
||||
日期:2026-09-12。首版已经冻结 1000 个可玩布局,20 章 × 50 关,直接接入原游戏运行时。旧版布局与独立进度保留。当前交付是经过规则、策略和静态画面检查的试玩版本;没有把自动通过当作已证明千关长期体验优良。
|
||||
|
||||
- [从第 1 关开始](http://localhost:4173/?campaign=v0.5&level=1)
|
||||
- [第 1000 关](http://localhost:4173/?campaign=v0.5&level=1000)
|
||||
- [制作审核页](http://localhost:4173/labs/campaign-review/index.html)
|
||||
|
||||
## 实际产出与验收
|
||||
|
||||
| 项目 | 本批结果 |
|
||||
| --- | ---: |
|
||||
| 冻结布局 / 独立求解成功 | ${levels.length} / ${validation.independentlySolved} |
|
||||
| 忽略颜色、名称并整体旋转/镜像归一后的不同布局 | ${validation.uniqueLayouts} |
|
||||
| 完整回放(作者解 + 最少旋转解 + 20 种策略) | ${validation.completeReplays} |
|
||||
| 通过回放的成功操作 | ${validation.successfulActions} |
|
||||
| 需要旋转 / 不需要旋转 | ${sum.rotationRequired} / ${levels.length - sum.rotationRequired} |
|
||||
| 最少旋转数的最高值 | ${sum.maxRequiredRotations} |
|
||||
| 最少旋转解中有长路的关卡 | ${sum.longRouteLevels} |
|
||||
| 最少旋转解中单次最长格步骤 / 最多转弯 | ${sum.longestSteps} / ${sum.longestBends} |
|
||||
| 每关 20 策略中出现长路的最低次数 | ${minLong} |
|
||||
| 每关 20 策略中出现指定主长路的最低次数 | ${minPrimary} |
|
||||
|
||||
长路的统计带是至少 6 次转弯;主目标还须满足该关制作卡的最低转弯数。格步骤允许一条路经过同一格的不同轨道,不等于不同格子的数量。以上是本引擎模型中的观察量,不是玩家通关率、观看率或平均时长。
|
||||
|
||||
## 形态、体验和难度
|
||||
|
||||
| 路线家族 | 关数 | 体验类型 | 关数 |
|
||||
| --- | ---: | --- | ---: |
|
||||
${Object.entries(families).map(([id, name], i) => { const k = Object.keys(kinds)[i]; return `| ${name} | ${sum.families[id] || 0} | ${k ? kinds[k] : '合计'} | ${k ? sum.experiences[k] || 0 : 1000} |`; }).join('\n')}
|
||||
|
||||
这六类是可变开口、可变规模和布子的路线家族,不是 1000 种独立美术外形。花瓣与编织使用棋子真正可以行驶的轨道配对。作者主棋子走过的配对中,属于强调造型的最低比例为 ${(sum.minHeroShapeRatio * 100).toFixed(1)}%;完整作者清盘覆盖全盘强调配对的最低比例为 ${(sum.minShapeUsage * 100).toFixed(1)}%。两个分母不同:主路确实属于造型,不代表每关会把整朵花的所有路线走遍。
|
||||
|
||||
结构难度 D1–D5 的实际数量为 ${Array.from({ length: 5 }, (_, i) => sum.difficultyTiers['D' + (i + 1)] || 0).join(' / ')}。分数使用必要旋转、放行深度、开局可走比例和扫掠阻挡,不加入长路转弯数;全部标记为未经玩家校准的结构预估。后期继续有直接巡游和小盘,不把关号、盘面尺寸或棋子数直接等同于难度。
|
||||
|
||||
| 章节 | 关号 | D1 / D2 / D3 / D4 / D5 | 棋子数范围 | 需旋转关数 | 画面 |
|
||||
| --- | --- | --- | --- | ---: | --- |
|
||||
${chapters.join('\n')}
|
||||
|
||||
## 制作逻辑与质量修正
|
||||
|
||||
先构造有形态的开放路线,再逆向布子并保存完整构造解。换路关与反差关包含旋转前互相阻挡、旋转后可放行的核心棋子;分段关使用经正式碰撞与求解器验证的两次换路锁组。每个槽位在同一家族和体验内比较合格候选,避免只追求一个总分。
|
||||
|
||||
每个清挡关的指定短路门在开局即可移走,移走后主棋子立刻获得长路;作者回放从这个关键动作开始。巡游关开局可连续点选。反差关主棋子朝内,旋转一次即可走上长路;分段关的最少解也必须有至少两次旋转。
|
||||
|
||||
独立审核曾发现旧候选中“另一枚长路替主目标过检”、关键长路过晚及随机探索大量绕过的问题。本批增加了指定主棋子的单独门槛:核心六策略至少四条走到主长路,20 条中至少 16 条有长路、至少 10 条走到主长路;清挡与反差另查实际兑现的放行事件。这个筛选阈值是制作约束,不是研究证明的最优比例。
|
||||
|
||||
视觉审核还退回了过于相似的 849/850 连排:849 改为中盘编织巡游,850 保留为章末重瓣分段释放。小盘布子受到实际容量限制,避免通过堆积棋子延长关卡。
|
||||
|
||||
## 操作顺序的证据边界
|
||||
|
||||
四种“先清所有可出棋子再转”的常规策略可能得到相同统计,不能视作四份独立玩家证据。表中每行只是一个确定的策略。14 条额外随机探索与核心策略分别记录;额外的未参与筛选种子由独立质量审核报告说明。
|
||||
|
||||
| 策略 | 回放数 | 清盘数 | 出现长路数 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
${policies.join('\n')}
|
||||
|
||||
## 画面、浏览器与尚未验证的范围
|
||||
|
||||
- [前 500 关静态审阅](v0.5-visual-audit-early.md)、[后 500 关静态审阅](v0.5-visual-audit-late.md):20 张逐章联系表的全局目检,不冒充 1000 次手玩。
|
||||
- [浏览器记录](v0.5-browser-review.md):快速连点、清挡即发、反差、分段解、第 1000 关结算、选关/版本/缩放的实际样本与尺寸。
|
||||
- [独立质量审核](v0.5-quality-audit.md):全量复查、主奖励出现时机、未参与筛选的策略与后续复审项。
|
||||
- \`levels/v0.5-reviews.json\` 单独保存 AI 辅助静态审阅与浏览器样本状态;布局分析中的 \`visual: pending\` 是生成时快照,审核页会并列显示后续记录。
|
||||
|
||||
客户端读取冻结关卡,不在进入每关时实时生成。当前实现按章显示 50 项,但关卡模块仍一次载入全部数据;本地加载验证不等同于移动网络性能验证。真人新手/熟练玩家的连续游玩、疲劳、图案辨识度及留存尚未测量。原生 Cocos/Android/iOS 运行与实机触控没有在本轮验收。
|
||||
|
||||
## 文件与复现
|
||||
|
||||
布局全集:\`levels/v0.5.json\`;分章:\`levels/v0.5/chapter-01.json\` 至 \`chapter-20.json\`;浏览器模块:\`src/levels-v05.js\`。逐关配方在 \`levels/v0.5-recipes.json\`,策略与实际路径在 \`levels/v0.5-analysis.json\`,本批独立验证在 \`levels/v0.5-validation.json\`。
|
||||
|
||||
流水线 SHA-256:\`${manifest.pipelineHash}\`。
|
||||
|
||||
\`npm run levels:v05\` 离线重新生产;\`npm run levels:v05:verify\` 重求解并回放冻结数据;\`node scripts/write-production-report.mjs\` 只对已通过对应哈希验证的数据更新本报告。源码指纹改变时不允许混用旧检查点。
|
||||
|
||||
方法与最初假设见[千关制作方案](1000-level-production-plan.md)、[设计准则](level-design-principles-v2.md)和[论文与竞品知识库](level-design-knowledge/README.md)。
|
||||
`;
|
||||
await writeFile(new URL('docs/v0.5-production-report.md', root), text);
|
||||
console.log('Wrote docs/v0.5-production-report.md from verified frozen data.');
|
||||
Reference in New Issue
Block a user