// Run from the project root: node tools/count-level-blocks.cjs // Counts level-configured blocks, including stacked upper blocks and spawn queues. const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const root = path.resolve(__dirname, '..'); const out = path.join(root, 'reports', 'level-block-counts'); const read = p => fs.readFileSync(path.join(root, p), 'utf8'); const colors = ['蓝(月亮)', '紫(星星)', '浅绿(圆)', '米黄(方形)', '青(三叶)', '橙(三角)', '深绿(菱形)', '黄(六边形)', '粉(爱心)', '红(皇冠)', '灰(障碍)']; const blockCode = read('assets/Script/Block.ts'); const shapeCode = blockCode.split(' initBlocks() {')[1].split('\n }')[0]; const shapes = [...shapeCode.matchAll(/case (\d+):([\s\S]*?)break;/g)].map(m => { const points = [...m[2].matchAll(/cc\.v2\((-?\d+), (-?\d+)\)/g)].map(p => [Number(p[1]), Number(p[2])]); const xs = points.map(p => p[0]), ys = points.map(p => p[1]); const width = Math.max(...xs) - Math.min(...xs) + 1; const height = Math.max(...ys) - Math.min(...ys) + 1; const diagram = []; for (let y = Math.max(...ys); y >= Math.min(...ys); y--) { let row = ''; for (let x = Math.min(...xs); x <= Math.max(...xs); x++) row += points.some(p => p[0] === x && p[1] === y) ? '■' : '·'; diagram.push(row); } return { id: Number(m[1]), width, height, cells: points.length, diagram: diagram.join('/') }; }); assert.equal(shapes.length, 23); const fresh = () => ({ levels: 0, raw: 0, barriers: 0, initialUpper: 0, spawnBase: 0, spawnUpper: 0, gates: 0, cycles: 0, matrix: Array.from({ length: 23 }, () => Array(11).fill(0)) }); const results = {}, levelDetails = []; for (const dir of ['Json', 'Json2']) { const total = fresh(); const files = fs.readdirSync(path.join(root, 'assets/custom', dir)).filter(f => /^level\d+\.json$/.test(f)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); total.levels = files.length; const ids = files.map(f => Number(f.match(/\d+/)[0])); total.missing = Array.from({ length: Math.max(...ids) }, (_, i) => i + 1).filter(id => !ids.includes(id)); for (const file of files) { const data = JSON.parse(read(`assets/custom/${dir}/${file}`)); assert.equal(data.BLOCK_INFO.length, 1, file); const item = fresh(); function add(block, stage) { assert.ok(Number.isInteger(block.block) && block.block >= 0 && block.block <= 23, file); if (stage === 'initial') item.raw++; else item.spawnBase++; if (block.block === 23) { item.barriers++; return; } let color = block.color; if (block.colorArray != null && String(block.colorArray).length) { color = Number(String(block.colorArray)[0]) + 1; item.cycles++; assert.equal(color, block.color, `${file}: initial cycle color differs`); } assert.ok(Number.isInteger(color) && color >= 1 && color <= 11, file); item.matrix[block.block][color - 1]++; if (block.type === 1) { assert.ok(Number.isInteger(block.stacking) && block.stacking >= 1 && block.stacking <= 11, file); item.matrix[block.block][block.stacking - 1]++; item[stage === 'initial' ? 'initialUpper' : 'spawnUpper']++; } } data.BLOCK_INFO[0].forEach(b => add(b, 'initial')); for (const level of data.LEVEL_INFO) for (const gate of level.spawnGates || []) { item.gates++; for (const batch of gate.spawnQueue) batch.members.forEach(b => add(b, 'spawn')); } for (const key of ['raw', 'barriers', 'initialUpper', 'spawnBase', 'spawnUpper', 'gates', 'cycles']) total[key] += item[key]; for (let s = 0; s < 23; s++) for (let c = 0; c < 11; c++) total.matrix[s][c] += item.matrix[s][c]; levelDetails.push({ source: dir, file, ...item }); } results[dir] = total; } const all = fresh(); for (const result of Object.values(results)) { for (const key of ['levels', 'raw', 'barriers', 'initialUpper', 'spawnBase', 'spawnUpper', 'gates', 'cycles']) all[key] += result[key]; for (let s = 0; s < 23; s++) for (let c = 0; c < 11; c++) all.matrix[s][c] += result.matrix[s][c]; } results.all = all; for (const result of [...Object.values(results), ...levelDetails]) { result.colorTotals = colors.map((_, c) => result.matrix.reduce((n, row) => n + row[c], 0)); result.shapeTotals = result.matrix.map(row => row.reduce((a, b) => a + b, 0)); result.blocks = result.colorTotals.reduce((a, b) => a + b, 0); result.includingBarriers = result.blocks + result.barriers; assert.equal(result.blocks, result.shapeTotals.reduce((a, b) => a + b, 0)); assert.equal(result.includingBarriers, result.raw + result.initialUpper + result.spawnBase + result.spawnUpper); } const table = (heads, rows) => ['| ' + heads.join(' | ') + ' |', '| ' + heads.map(() => '---').join(' | ') + ' |', ...rows.map(row => '| ' + row.join(' | ') + ' |')].join('\n'); const n = value => value.toLocaleString('en-US'); const labels = { Json: '主关卡 Json', Json2: '附加数据 Json2', all: '全部文件合计' }; let md = '# 所有关卡方块统计\n\n统计日期:2026-09-15。数据为本地工作区当前文件。\n\n'; md += '## 统计口径\n\n- 主关卡来自 `assets/custom/Json/level*.json`;当前游戏加载路径为 `custom` 分包下的 `Json/levelN`。`Json2` 的 50 个文件单列,全部文件合计包含两套数据,不按相同关卡编号去重。\n- 数量单位是“块”:每个形状整体算一块,叠加块上下层各算一块。包含特殊块、灰色障碍块及生成门队列里的全部方块(假定队列全部生成)。不按占用格数累加。\n- 变色块按初始颜色计一次;问号块按配置底色计数,揭示前的问号外壳不另算一块。\n- `block=23` 由 Barrier 组件创建,其 `color` 字段不用于设置颜色,因此单列为障碍物,不归入彩色方块。门、地板覆盖层、花瓣等附属物不另计为方块。\n- 颜色名称依据实际贴图外观,并附图案便于核对;`BlockColor` 枚举名称与当前贴图不符,未使用该枚举命名。灰色 `color=11` 使用 `0color` 贴图。\n- 规格为宽×高(格),不同朝向按 `block` 编号分开;形状示意中的 `/` 表示换行,`■` 为占用格,`·` 为空格。\n\n'; md += '## 总量核对\n\n' + table(['数据集', '关卡文件', '配置记录', '叠加上层', '生成队列记录', '队列叠加上层', '方块(0–22)', '障碍物(23)', '含障碍物总量'], Object.entries(results).map(([key, r]) => [labels[key], ...[r.levels, r.raw, r.initialUpper, r.spawnBase, r.spawnUpper, r.blocks, r.barriers, r.includingBarriers].map(n)])) + '\n\n'; md += `主关卡编号范围 1–1957,缺少 ${results.Json.missing.join('、')};仅统计实际存在的 ${results.Json.levels} 个文件。\n\n`; md += '## 按颜色汇总(含叠加层和生成队列,不含 block=23)\n\n' + table(['color', '贴图颜色/图案', '主关卡 Json', 'Json2', '全部文件合计'], colors.map((color, c) => [c + 1, color, ...Object.values(results).map(r => n(r.colorTotals[c]))])) + '\n\n'; for (const key of ['all', 'Json', 'Json2']) { const r = results[key]; md += `## ${labels[key]}:颜色 × 规格\n\n`; md += table(['block', '规格', '占格', '形状', ...colors.map((c, i) => `${i + 1} ${c.split('(')[0]}`), '合计'], shapes.map(s => [s.id, `${s.width}×${s.height}`, s.cells, '`' + s.diagram + '`', ...r.matrix[s.id].map(n), n(r.shapeTotals[s.id])]).concat([['合计', '', '', '', ...r.colorTotals.map(n), n(r.blocks)]])) + `\n\n另有 block=23 障碍物 **${n(r.barriers)}** 个。\n\n`; } md += '## 核对依据\n\n- `assets/Script/Map.ts`:`blockInit` 的叠加层创建、`createSpawnGateBatch` 的队列创建。\n- `assets/Script/Block.ts`:`initColor` 的初始颜色、`initBlocks` 的 23 种形状占格。\n- `assets/Script/Barrier.ts`:block=23 不按颜色字段换图。\n- `assets/Block/0color0.png` 至 `10color0.png`:颜色及图案。\n- 校验结果:全部 JSON 成功解析;所有颜色、规格编号通过检查;每关以及每套数据的行列合计一致;配置记录 + 叠加上层 + 生成队列记录 + 队列上层 = 含障碍物总量。\n'; fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, '所有关卡方块统计.md'), md); fs.writeFileSync(path.join(out, 'counts.json'), JSON.stringify({ colors, shapes, results, levelDetails }, null, 2)); console.log(JSON.stringify(Object.fromEntries(Object.entries(results).map(([k, { matrix, ...rest }]) => [k, rest])), null, 2));