绘制修复(mini-desktop CDP 复验揪出:战斗屏除回合横幅外 lumStd=0 全空,逻辑与指针全通):根因=run-scene.js
const txt 声明位于工厂 return 之后永不执行,txt 永陷 TDZ,全部整屏绘制函数每帧 ReferenceError 无声失败,
唯 floatText(function 声明提升)独活=只剩横幅;M1 并入 run-scene 时引入,headless 门不建场景故只有真浏览器
暴露。修=txt 改 function 声明(提升语义)+事故注释钉原地+自证钩子(场景 rebuild 后回写
readState().debug={displayCount,rebuilds,screen} 供 CDP 机断)+防回潮门 scene-smoke.mjs(mock 场景重放十屏/
异常零容忍/每屏绘制对象≥5;修复后 map=33/battle=32/reward=14)。
M3:S13 职业乙 66+中立 22=154 卡定容(static-screen 3,676,134 序列零经济环)+S14 进阶 ASCENSIONS 16 级入
矩阵轴+多样性 driver(chain/defensive 档,死卡候选 42 张如实挂账 README)+S15 meta 三件(图鉴/成就 40/每日,
meta-core.js+meta.test)+S16 事件 45;存档 v2 双域+v1 迁移链(19 断言);home 局外模式机+终局结算。
门证据:八门全绿(battle-core/logic-smoke/run-core+R15 职业进阶 7 断言/run-smoke/save v2 19 断言/meta/
static-screen/scene-smoke);六带全绿 nightly n=1000/格(甲 A0 58.2%/A5 32.2%/A15 6.9%,乙 55.1%/28.3%/5.7%,
带 45–60/25–40/5–15);构筑指纹去重 A0 六格全 100%(门>85% 草案);金标 26 套(甲 20 漂移≤3.6pp 全 PASS+乙 6 首锁);
**全矩阵 96,000 局书面口径首次真跑=158.6s、nightly 总 223.2s**;平衡轮主杠杆=职业起始遗物 +36.6pp;
主会话真跑复验八门+PR 层。浏览器像素复验随本笔 push 后触发。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
14 KiB
JavaScript
270 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/asset-pack.mjs —— 《山海巡异录》资产一期 · 产线打包(抠图→缩尺→图集→manifest→机检)
|
|
// owner:美术批产执行席(2026-07-06;批次一 key-out.py / atlas-pack.mjs 迁移整合版)
|
|
// 用法:
|
|
// node scripts/asset-pack.mjs --raw-dir <生图原图目录> # 全量打包
|
|
// node scripts/asset-pack.mjs --raw-dir <目录> --group cards # 单组
|
|
// 产物:
|
|
// assets/atlas/<atlas>-N.webp + .json 图集(Phaser3 坐标,webp q80;卡插画独立分包 cards-jia)
|
|
// assets/bg/<id>.webp 满幅大图(场景/结算,批次一复用件标 source)
|
|
// assets/manifest.json 资产清单 + 字节账(账A 输入)
|
|
// docs-design/山海风格版-批次一/images-巡异录资产一期/<group>/ 定稿缩尺评审版(webp)
|
|
// 机检(每组):id 齐套 / 精灵透明底占比 / 尺寸合规 / 图集帧数对账;结果打印并追加台账(kind=qa-pack)。
|
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync } from 'node:fs';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { SHOTS, REUSE, CARD_IDS, RELIC_IDS } from './asset-shots.mjs';
|
|
|
|
const GAME_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const REPO_ROOT = resolve(GAME_ROOT, '..', '..', '..');
|
|
const BATCH1 = join(REPO_ROOT, 'docs-design', '山海风格版-批次一');
|
|
const REVIEW_DIR = join(BATCH1, 'images-巡异录资产一期');
|
|
const MANIFEST_LEDGER = join(BATCH1, 'manifest-巡异录资产一期.jsonl');
|
|
const ASSETS = join(GAME_ROOT, 'assets');
|
|
const ATLAS_DIR = join(ASSETS, 'atlas');
|
|
const BG_DIR = join(ASSETS, 'bg');
|
|
|
|
const args = process.argv.slice(2);
|
|
const getArg = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
const RAW_DIR = resolve(getArg('--raw-dir') || join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-raw'));
|
|
const onlyGroup = getArg('--group');
|
|
const WORK = join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-pack');
|
|
const STAGE = join(WORK, 'stage'); // 按 atlas 分目录的定稿 png(图集输入)
|
|
const KEYED = join(WORK, 'keyed'); // 抠图中间产物
|
|
|
|
for (const d of [WORK, STAGE, KEYED, ATLAS_DIR, BG_DIR, REVIEW_DIR]) mkdirSync(d, { recursive: true });
|
|
|
|
const findRaw = (group, id) => {
|
|
const dir = join(RAW_DIR, group);
|
|
if (!existsSync(dir)) return null;
|
|
const f = readdirSync(dir).find((x) => x.startsWith(id + '.'));
|
|
return f ? join(dir, f) : null;
|
|
};
|
|
|
|
// ── 批量图像操作:一次 python 进程吃一张作业单(缩尺/转码;PIL LANCZOS)──
|
|
function runImageOps(ops, label) {
|
|
if (!ops.length) return;
|
|
const jobFile = join(WORK, `ops-${label}.json`);
|
|
writeFileSync(jobFile, JSON.stringify(ops));
|
|
const py = `
|
|
import json, sys, os
|
|
from PIL import Image
|
|
ops = json.load(open(sys.argv[1]))
|
|
for op in ops:
|
|
im = Image.open(op['src'])
|
|
if op['mode'] == 'cover': # 等比放缩后居中裁到目标框(满幅件)
|
|
sw, sh = im.size; tw, th = op['w'], op['h']
|
|
s = max(tw / sw, th / sh)
|
|
im = im.resize((max(1, round(sw * s)), max(1, round(sh * s))), Image.LANCZOS)
|
|
l, t = (im.width - tw) // 2, (im.height - th) // 2
|
|
im = im.crop((l, t, l + tw, t + th))
|
|
elif op['mode'] == 'contain': # 最长边限界(透明底精灵,保比例)
|
|
im.thumbnail((op['box'], op['box']), Image.LANCZOS)
|
|
os.makedirs(os.path.dirname(op['dst']), exist_ok=True)
|
|
if op['dst'].endswith('.webp'):
|
|
im.save(op['dst'], quality=80, method=6)
|
|
else:
|
|
im.save(op['dst'])
|
|
`;
|
|
execFileSync('python3', ['-c', py, jobFile], { stdio: 'inherit', timeout: 300_000 });
|
|
}
|
|
|
|
// ── 抠图:批次一 key-out.py 原脚本复用(色键+连通域除杂,印章尘点自动清)──
|
|
function keyOut(files, outDir) {
|
|
if (!files.length) return;
|
|
mkdirSync(outDir, { recursive: true });
|
|
execFileSync('python3', [join(BATCH1, 'scripts', 'key-out.py'), ...files, '--out', outDir], { stdio: 'inherit', timeout: 600_000 });
|
|
}
|
|
|
|
// ── 透明底机检:alpha 通道存在且全透明像素占比在合理带(防抠图失效/全空)──
|
|
function alphaCheck(pngPaths) {
|
|
if (!pngPaths.length) return [];
|
|
const jobFile = join(WORK, 'alpha-check.json');
|
|
writeFileSync(jobFile, JSON.stringify(pngPaths));
|
|
const py = `
|
|
import json, sys
|
|
import numpy as np
|
|
from PIL import Image
|
|
bad = []
|
|
for p in json.load(open(sys.argv[1])):
|
|
im = Image.open(p)
|
|
if im.mode != 'RGBA':
|
|
bad.append({'file': p, 'why': 'no-alpha'}); continue
|
|
a = np.asarray(im)[:, :, 3]
|
|
frac = float((a == 0).mean())
|
|
if frac < 0.03: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过低(疑似未抠净)'})
|
|
if frac > 0.98: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过高(疑似全空)'})
|
|
print(json.dumps(bad))
|
|
`;
|
|
const r = execFileSync('python3', ['-c', py, jobFile], { encoding: 'utf8', timeout: 300_000 });
|
|
return JSON.parse(r.trim() || '[]');
|
|
}
|
|
|
|
// ── 图集:free-tex-packer-cli(schema 同批次一)→ png 页 → webp q80 → JSON 改指 webp ──
|
|
function packAtlas(atlasName, stageDir, pageSize) {
|
|
const pngs = readdirSync(stageDir).filter((f) => f.endsWith('.png'));
|
|
if (!pngs.length) return null;
|
|
const outTmp = join(WORK, 'atlas-out', atlasName);
|
|
rmSync(outTmp, { recursive: true, force: true });
|
|
mkdirSync(outTmp, { recursive: true });
|
|
const project = {
|
|
savePath: outTmp,
|
|
images: pngs.map((f) => ({ name: f, path: join(stageDir, f) })),
|
|
folders: [],
|
|
packOptions: {
|
|
textureName: atlasName,
|
|
width: pageSize, height: pageSize, fixedSize: false, powerOfTwo: false,
|
|
padding: 2, extrude: 1, allowRotation: false, detectIdentical: true,
|
|
allowTrim: true, trimMode: 'trim', alphaThreshold: 0,
|
|
exporter: 'Phaser3', removeFileExtension: true, prependFolderName: false,
|
|
textureFormat: 'png', base64Export: false, scale: 1, packer: 'MaxRectsPacker',
|
|
},
|
|
};
|
|
const proj = join(outTmp, `${atlasName}.ftpp`);
|
|
writeFileSync(proj, JSON.stringify(project, null, 2));
|
|
execFileSync('free-tex-packer-cli', ['--project', proj, '--output', outTmp], { stdio: 'inherit', timeout: 300_000 });
|
|
|
|
const pages = [];
|
|
for (const png of readdirSync(outTmp).filter((f) => f.startsWith(atlasName) && f.endsWith('.png'))) {
|
|
const base = png.replace(/\.png$/, '');
|
|
const jsonPath = join(outTmp, `${base}.json`);
|
|
const webpDst = join(ATLAS_DIR, `${base}.webp`);
|
|
runImageOps([{ src: join(outTmp, png), dst: webpDst, mode: 'contain', box: pageSize }], `webp-${base}`);
|
|
const j = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
|
for (const t of j.textures || []) if (t.image) t.image = t.image.replace(/\.png$/, '.webp');
|
|
const jsonDst = join(ATLAS_DIR, `${base}.json`);
|
|
writeFileSync(jsonDst, JSON.stringify(j, null, 2));
|
|
const frames = (j.textures || []).reduce((s, t) => s + (t.frames?.length || 0), 0);
|
|
pages.push({ image: `atlas/${base}.webp`, json: `atlas/${base}.json`, bytes: statSync(webpDst).size, frames });
|
|
}
|
|
return pages;
|
|
}
|
|
|
|
const kb = (n) => `${(n / 1024).toFixed(0)}KB`;
|
|
const problems = [];
|
|
const qa = { missingRaw: [], alphaBad: [], counts: {}, bytes: {} };
|
|
|
|
// ═══ 1. 分组落 stage ═══
|
|
const groups = ['cards', 'beasts', 'ui', 'relics', 'bg'].filter((g) => !onlyGroup || g === onlyGroup);
|
|
|
|
// cards / ui:bleed,cover 缩尺直接进 stage(frame 名 = 去前缀 id)
|
|
for (const g of groups.filter((x) => x === 'cards' || x === 'ui')) {
|
|
const ops = [];
|
|
for (const s of SHOTS.filter((x) => x.group === g)) {
|
|
const raw = findRaw(g, s.id);
|
|
if (!raw) { qa.missingRaw.push(s.id); continue; }
|
|
ops.push({ src: raw, dst: join(STAGE, s.atlas, `${s.frame}.png`), mode: 'cover', w: s.final.w, h: s.final.h });
|
|
ops.push({ src: raw, dst: join(REVIEW_DIR, g, `${s.frame}.webp`), mode: 'cover', w: s.final.w, h: s.final.h });
|
|
}
|
|
runImageOps(ops, g);
|
|
}
|
|
|
|
// beasts / relics:sprite,先抠图再 contain 缩尺
|
|
for (const g of groups.filter((x) => x === 'beasts' || x === 'relics')) {
|
|
const shots = SHOTS.filter((x) => x.group === g);
|
|
const toKey = [];
|
|
const meta = []; // {keyedPng, atlas, frame, box, group}
|
|
for (const s of shots) {
|
|
const raw = findRaw(g, s.id);
|
|
if (!raw) { qa.missingRaw.push(s.id); continue; }
|
|
toKey.push(raw);
|
|
meta.push({ keyed: join(KEYED, g, `${s.id}.png`), atlas: s.atlas, frame: s.frame, box: s.final.box });
|
|
}
|
|
if (g === 'beasts') {
|
|
for (const r of REUSE.beasts) { // 批次一定稿复用(创始人已眼验)
|
|
toKey.push(join(BATCH1, r.src));
|
|
const base = r.src.split('/').pop().replace(/\.[a-z]+$/, '');
|
|
meta.push({ keyed: join(KEYED, g, `${base}.png`), atlas: r.atlas, frame: r.id, box: r.box });
|
|
}
|
|
}
|
|
keyOut(toKey, join(KEYED, g));
|
|
const bad = alphaCheck(meta.map((m) => m.keyed).filter((p) => existsSync(p)));
|
|
qa.alphaBad.push(...bad);
|
|
const ops = [];
|
|
for (const m of meta) {
|
|
if (!existsSync(m.keyed)) { problems.push(`${g}:抠图无产物 ${m.keyed}`); continue; }
|
|
ops.push({ src: m.keyed, dst: join(STAGE, m.atlas, `${m.frame}.png`), mode: 'contain', box: m.box });
|
|
ops.push({ src: m.keyed, dst: join(REVIEW_DIR, g, `${m.frame}.webp`), mode: 'contain', box: m.box });
|
|
}
|
|
runImageOps(ops, g);
|
|
}
|
|
|
|
// bg:新产 2 + 批次一复用 4,webp q80 原尺进 assets/bg
|
|
const bgFiles = [];
|
|
if (groups.includes('bg')) {
|
|
const ops = [];
|
|
for (const s of SHOTS.filter((x) => x.group === 'bg')) {
|
|
const raw = findRaw('bg', s.id);
|
|
if (!raw) { qa.missingRaw.push(s.id); continue; }
|
|
ops.push({ src: raw, dst: join(BG_DIR, `${s.id}.webp`), mode: 'cover', w: s.final.w, h: s.final.h });
|
|
bgFiles.push({ id: s.id, source: '资产一期新产' });
|
|
}
|
|
for (const r of REUSE.bg) {
|
|
ops.push({ src: join(BATCH1, r.src), dst: join(BG_DIR, `${r.id}.webp`), mode: 'cover', w: 768, h: 1360 });
|
|
bgFiles.push({ id: r.id, source: '批次一定稿复用' });
|
|
}
|
|
runImageOps(ops, 'bg');
|
|
for (const f of bgFiles) {
|
|
const p = join(BG_DIR, `${f.id}.webp`);
|
|
if (existsSync(p)) { f.file = `bg/${f.id}.webp`; f.bytes = statSync(p).size; copyFileSync(p, join(REVIEW_DIR, 'bg', `${f.id}.webp`)); }
|
|
}
|
|
mkdirSync(join(REVIEW_DIR, 'bg'), { recursive: true });
|
|
}
|
|
|
|
// ═══ 2. 打图集 ═══
|
|
const PAGE_SIZE = { 'cards-jia': 2048, 'beasts-act1': 2048, 'beasts-act2': 2048, 'beasts-act3': 2048, relics: 1024, ui: 2048 };
|
|
const atlases = {};
|
|
for (const atlasName of Object.keys(PAGE_SIZE)) {
|
|
const g = atlasName.startsWith('cards') ? 'cards' : atlasName.startsWith('beasts') ? 'beasts' : atlasName;
|
|
if (!groups.includes(g)) continue;
|
|
const stageDir = join(STAGE, atlasName);
|
|
if (!existsSync(stageDir)) continue;
|
|
const pages = packAtlas(atlasName, stageDir, PAGE_SIZE[atlasName]);
|
|
if (pages) atlases[atlasName] = pages;
|
|
}
|
|
|
|
// ═══ 3. 机检对账 ═══
|
|
const expect = {
|
|
'cards-jia': CARD_IDS.length, // 66
|
|
relics: RELIC_IDS.length, // 40
|
|
ui: SHOTS.filter((s) => s.group === 'ui').length, // 15
|
|
beastsTotal: SHOTS.filter((s) => s.group === 'beasts').length + REUSE.beasts.length, // 39
|
|
};
|
|
const frameCount = (a) => (atlases[a] || []).reduce((s, p) => s + p.frames, 0);
|
|
if (groups.includes('cards') && frameCount('cards-jia') !== expect['cards-jia']) problems.push(`cards-jia 帧数 ${frameCount('cards-jia')} ≠ ${expect['cards-jia']}`);
|
|
if (groups.includes('relics') && frameCount('relics') !== expect.relics) problems.push(`relics 帧数 ${frameCount('relics')} ≠ ${expect.relics}`);
|
|
if (groups.includes('ui') && frameCount('ui') !== expect.ui) problems.push(`ui 帧数 ${frameCount('ui')} ≠ ${expect.ui}`);
|
|
const beastFrames = frameCount('beasts-act1') + frameCount('beasts-act2') + frameCount('beasts-act3');
|
|
if (groups.includes('beasts') && beastFrames !== expect.beastsTotal) problems.push(`beasts 帧数 ${beastFrames} ≠ ${expect.beastsTotal}`);
|
|
if (qa.missingRaw.length) problems.push(`缺原图 ${qa.missingRaw.length}:${qa.missingRaw.join(',')}`);
|
|
for (const b of qa.alphaBad) problems.push(`透明底异常 ${b.file}(${b.why})`);
|
|
|
|
// ═══ 4. manifest + 字节账 ═══
|
|
const byGroup = {};
|
|
for (const [name, pages] of Object.entries(atlases)) byGroup[name] = pages.reduce((s, p) => s + p.bytes, 0);
|
|
byGroup.bg = bgFiles.reduce((s, f) => s + (f.bytes || 0), 0);
|
|
const totalBytes = Object.values(byGroup).reduce((s, n) => s + n, 0);
|
|
const manifest = {
|
|
version: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
anchor: 'docs-design/山海风格版-批次一/anchor-pack.json(风格锚 v1 + 工艺修订 r1,单源)',
|
|
ledger: 'docs-design/山海风格版-批次一/manifest-巡异录资产一期.jsonl',
|
|
snapshotNote: '卡/遗物/敌帧名对齐 2026-07-06 tables.js 快照(66 卡/40 遗物/8 敌)+ 兽谱-增补-巡异录.md 31 兽;职业乙插画位未产,等 M3 表落地下一批补齐',
|
|
atlases,
|
|
bg: bgFiles,
|
|
totals: { webpBytes: totalBytes, byGroup },
|
|
};
|
|
if (!onlyGroup) writeFileSync(join(ASSETS, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
|
|
appendFileSync(MANIFEST_LEDGER, JSON.stringify({ ts: new Date().toISOString(), kind: 'qa-pack', groups, problems, byGroup, totalBytes }) + '\n');
|
|
|
|
console.log('\n═══ 打包结果 ═══');
|
|
for (const [name, pages] of Object.entries(atlases)) {
|
|
console.log(`${name}:${pages.length} 页 / ${pages.reduce((s, p) => s + p.frames, 0)} 帧 / ${kb(byGroup[name])}`);
|
|
}
|
|
if (bgFiles.length) console.log(`bg:${bgFiles.length} 张 / ${kb(byGroup.bg)}`);
|
|
console.log(`合计 webp:${kb(totalBytes)}(${(totalBytes / 1048576).toFixed(2)}MB)`);
|
|
if (problems.length) { console.error(`\n机检 ${problems.length} 项异常:`); for (const p of problems) console.error(' - ' + p); process.exit(1); }
|
|
console.log('机检:全部通过');
|