185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
/**
|
||
* Publish `samples/d2-packs` to the asset repository.
|
||
*
|
||
* The baked atlas packs are ~66 MB of indexed PNG. PNG is already deflate
|
||
* compressed, so git's delta compression buys almost nothing: every re-bake changes
|
||
* nearly every file, and a normal commit would grow the repository by another ~60 MB
|
||
* each time (measured: the code repository went 63 MB → 117 MB over two bakes).
|
||
*
|
||
* So the asset repository deliberately keeps **exactly one commit**: every publish
|
||
* creates an orphan history (a brand new repository in a temp directory), commits the
|
||
* current packs, and force-pushes it over the remote. The repository therefore stays
|
||
* at "one snapshot" (~66 MB) no matter how often the packs are re-baked, and the code
|
||
* repository — which no longer contains the packs — clones in a couple of megabytes.
|
||
*
|
||
* Usage:
|
||
* ```
|
||
* node scripts/publish-packs.ts # default remote
|
||
* node scripts/publish-packs.ts <git-remote-url> # explicit remote
|
||
* ```
|
||
*
|
||
* The remote needs credentials; either pass a URL with them, or run
|
||
* `git config --global credential.helper store` once so `git push` does not prompt.
|
||
* Nothing here writes a password to disk.
|
||
*
|
||
* @module
|
||
*/
|
||
|
||
import { execFileSync } from 'node:child_process'
|
||
import {
|
||
cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync,
|
||
} from 'node:fs'
|
||
import { createHash } from 'node:crypto'
|
||
import { tmpdir } from 'node:os'
|
||
import { join, resolve } from 'node:path'
|
||
|
||
/** Where the packs live inside the code repository. */
|
||
const PACKS_DIR = resolve(import.meta.dirname, '..', 'samples', 'd2-packs')
|
||
|
||
/** The asset repository, without credentials. */
|
||
const DEFAULT_REMOTE = 'https://git.projectdiablo2.cn/troytt/diablo2-web-assets.git'
|
||
|
||
/** The code repository, linked from the generated README. */
|
||
const CODE_REPO = 'https://git.projectdiablo2.cn/troytt/diablo2-web'
|
||
|
||
/** The archive mirror served next to the deployment. */
|
||
const ARCHIVE_BASE = 'https://www.laiseek.xyz/diablo2/assets'
|
||
|
||
/** Raised when the packs are missing or git fails. */
|
||
class PublishError extends Error {}
|
||
|
||
/**
|
||
* Run one git command in `cwd`, inheriting stdio so progress is visible.
|
||
*
|
||
* @param args - git arguments.
|
||
* @param cwd - directory to run in.
|
||
*/
|
||
function git(args: readonly string[], cwd: string): void {
|
||
try {
|
||
execFileSync('git', args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] })
|
||
} catch {
|
||
throw new PublishError(`git ${args.join(' ')} failed`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Collect the statistics that go into the generated README.
|
||
*
|
||
* @returns file count, total bytes, map-block count and the `index.json` hash.
|
||
*/
|
||
function measure(): { files: number; bytes: number; maps: number; indexSha: string } {
|
||
const indexPath = join(PACKS_DIR, 'index.json')
|
||
if (!existsSync(indexPath)) {
|
||
throw new PublishError(`no packs at ${PACKS_DIR} — run: npm run pack:data`)
|
||
}
|
||
const index = JSON.parse(readFileSync(indexPath, 'utf8')) as { entries?: unknown[]; levels?: unknown[] }
|
||
const indexSha = createHash('sha256').update(readFileSync(indexPath)).digest('hex')
|
||
let files = 0
|
||
let bytes = 0
|
||
const walk = (dir: string): void => {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const path = join(dir, entry.name)
|
||
if (entry.isDirectory()) walk(path)
|
||
else {
|
||
files += 1
|
||
bytes += statSync(path).size
|
||
}
|
||
}
|
||
}
|
||
walk(PACKS_DIR)
|
||
return { files, bytes, maps: index.levels?.length ?? index.entries?.length ?? 0, indexSha }
|
||
}
|
||
|
||
/**
|
||
* Build the README that ships inside the asset repository.
|
||
*
|
||
* @param stats - output of {@link measure}.
|
||
* @param stamp - `YYYY-MM-DD` bake date.
|
||
* @returns the markdown text.
|
||
*/
|
||
function readme(stats: ReturnType<typeof measure>, stamp: string): string {
|
||
const mb = (stats.bytes / 1048576).toFixed(1)
|
||
return `# d2web 烘焙图集包(${stamp})
|
||
|
||
这是 [\`troytt/diablo2-web\`](${CODE_REPO}) 的**烘焙产物**:把原版暗黑破坏神 II(1.13c)的地图
|
||
解包成 web 原生格式——索引色 PNG 图集页 + \`scene.json\`(画家序绘制列表、碰撞 RLE、出生点、
|
||
对象元数据)+ \`index.json\`(含各 act 调色板)。
|
||
|
||
- 地图块 **${stats.maps} 个**(覆盖全 5 幕 136 个关卡预置与随机变体),文件 **${stats.files} 个**,合计 **${mb} MB**
|
||
- \`index.json\` sha256:\`${stats.indexSha}\`
|
||
- 生成时间:${stamp}
|
||
|
||
## 本仓库为什么只有一个提交
|
||
|
||
图集页是索引色 PNG(deflate 已压缩),git 的 delta 压缩对它几乎无收益:每次重烘几乎全部文件
|
||
都会变。若按普通方式提交,每烘焙一次仓库就再长 ~60 MB(实测代码仓库因此从 63 MB 涨到 117 MB)。
|
||
所以这里刻意**只保留一个提交**:每次重烘用 orphan 提交 + force push 覆盖,仓库体积恒定在
|
||
「一份快照」≈${Math.round(stats.bytes / 1048576)} MB。历史没有价值——需要旧版请从代码仓库
|
||
对应提交重新烘焙。
|
||
|
||
## 怎么用
|
||
|
||
\`\`\`bash
|
||
# 放进代码仓库的 samples/d2-packs/,页面默认就吃这个目录
|
||
git clone ${DEFAULT_REMOTE} samples/d2-packs
|
||
\`\`\`
|
||
|
||
只要一份压缩包(不需要 git):
|
||
|
||
\`\`\`bash
|
||
curl -O ${ARCHIVE_BASE}/d2-packs-${stamp}.tar.xz
|
||
tar xJf d2-packs-${stamp}.tar.xz
|
||
\`\`\`
|
||
|
||
## 怎么重新生成并发布
|
||
|
||
在代码仓库里(需要自备原版 MPQ,放在 \`samples/d2/\`):
|
||
|
||
\`\`\`bash
|
||
npm run pack:data # scripts/pack-act-assets.ts → samples/d2-packs(约 2 分钟)
|
||
npm run verify:packs # 与"现读现解"逐项比对:绘制序、碰撞栅格逐字节、每帧像素哈希
|
||
npm run publish:packs # orphan 提交 + force push 覆盖本仓库
|
||
\`\`\`
|
||
|
||
## 许可
|
||
|
||
内容为原版游戏美术的解码产物,仅供**持有正版游戏数据**的人在本项目内使用;请勿再分发。
|
||
原版 MPQ 与第三方参考源码都不在本仓库内。
|
||
`
|
||
}
|
||
|
||
const remote = process.argv[2] ?? process.env['D2_ASSETS_REMOTE'] ?? DEFAULT_REMOTE
|
||
const stats = measure()
|
||
const stamp = new Date().toISOString().slice(0, 10)
|
||
const work = mkdtempSync(join(tmpdir(), 'd2-assets-'))
|
||
|
||
try {
|
||
console.log(`图集包:${String(stats.maps)} 块地图 / ${String(stats.files)} 个文件 / ${(stats.bytes / 1048576).toFixed(1)} MB`)
|
||
console.log(`index.json sha256:${stats.indexSha}`)
|
||
console.log(`临时仓库:${work}`)
|
||
|
||
git(['init', '-q', '-b', 'main'], work)
|
||
cpSync(PACKS_DIR, work, { recursive: true })
|
||
writeFileSync(join(work, 'README.md'), readme(stats, stamp))
|
||
writeFileSync(join(work, '.gitattributes'), '*.png binary -diff\n*.json text eol=lf\n')
|
||
git(['add', '-A'], work)
|
||
git([
|
||
'-c', 'user.name=d2web', '-c', 'user.email=d2web@localhost',
|
||
'commit', '-q', '-m',
|
||
`烘焙图集包 ${stamp}:${String(stats.maps)} 个地图块(${(stats.bytes / 1048576).toFixed(1)} MB)
|
||
|
||
本仓库刻意只保留一个提交:图集页是索引色 PNG,git 的 delta 压缩对它几乎无收益,
|
||
每次重烘几乎全部文件都变;普通提交会让仓库每烘焙一次再长 ~60 MB。所以每次重烘都
|
||
用 orphan 提交 + force push 覆盖(见 README)。
|
||
|
||
index.json sha256: ${stats.indexSha}
|
||
|
||
由 troytt/diablo2-web 的 npm run pack:data 生成。`,
|
||
], work)
|
||
console.log(`推送到 ${remote}(force,覆盖历史)…`)
|
||
git(['push', '--force', remote, 'main'], work)
|
||
console.log('完成。使用方:git clone ' + remote + ' samples/d2-packs')
|
||
} finally {
|
||
rmSync(work, { recursive: true, force: true })
|
||
}
|