0.2.5-dev: official battlefield scenes on the 3D board — a local fetch / extract step for the season scene bundles, per-field scene rendering with the metal board as fallback, the full bundle can include the extracted scenes and setup can fetch them (--scenes); nothing official is stored in the repository (PR #533 by @andrew05060414, ported; the owner's decision of 2026-10-12); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 14:59:20 +08:00
1 parent 7fd0478dc0
commit d482fbb8ba
19 files changed
+1659 -22

No files matched your search

+1 -1
View File
@@ -251,7 +251,7 @@ async function main() {
const local = checkLocal();
const client = findClient(null);
row(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest
? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}`
? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.scenes ? ',已提取各战场官方场景' : ',无各战场官方场景(3D 棋盘用金属地块;node tools/setup.mjs --scenes)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}`
: client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : `未提取:${LOCAL_ART_FALLBACK}(${LOCAL_ART_COPY_HINT})`);
if (client || local.manifest) {
const py = findPython();
+50 -2
View File
@@ -34,6 +34,10 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
source as <name>_rgb.png / _rough.png
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
the manifest lists the copy
- the official battlefield scenes (scenes.py SCENES: one prebuilt scene per 卫戍协议 battlefield) → scene/<stageId>/
scene.json + scene.bin + lightmap pages and scene/tex/ (the materials' albedo textures). The scene bundles ship
only while the season runs: they are read from the client when it has them, else from --scenes <dir> (default
.cache/official-scenes/*, filled by tools/local-extract/fetch_scenes.py)
Usage:
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
@@ -41,6 +45,7 @@ Usage:
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
e.g. the local art copied from a release bundle; needs Pillow)
.venv/bin/python tools/local-extract/extract.py --only scene [--scenes <dir of in-season bundles>]
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
@@ -55,6 +60,7 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import scenes # noqa: E402 (the battlefield scene table and exporter; imports UnityPy only when it runs)
ROOT = Path(__file__).resolve().parents[2]
HOME = Path.home()
@@ -722,6 +728,39 @@ def write_spine(objects, sda, sub, name, out_root, manifest, log):
return len(files) + len(pages)
def scene_ids(only):
"""The stage ids of scenes.SCENES that run under --only (all with no prefix, `scene` or a parent of it)."""
return spine_ids(only, scenes.SCENE_SUB, [sid for sid, _deps in scenes.SCENES])
def scene_roots(ab_root, extra):
"""AB roots searched for the scene bundles, in order: the client, --scenes dirs, then every version kept under
.cache/official-scenes (newest name first)."""
kept = ROOT / '.cache' / 'official-scenes'
cached = sorted((p for p in kept.iterdir() if p.is_dir()), reverse=True) if kept.is_dir() else []
return [Path(ab_root), *[Path(p) for p in extra], *cached]
def export_scenes(ab_root, out_root, manifest, log, ids, roots):
"""scenes.SCENES (the ids in `ids`) → scene/<stageId>/ (scenes.export_scene); a stage without a scene bundle in
any root is skipped."""
root = Path(ab_root)
shader_deps = dep_bundles(root, {'Material'})
n = 0
for sid, deps in scenes.SCENES:
if sid not in ids:
continue
src = scenes.find_scene(sid, roots)
if src is None:
log(f'skip (no scene bundle: off-season client, see --scenes) {scenes.scene_bundle(sid)}')
continue
try:
n += scenes.export_scene(sid, deps, src, root, out_root, manifest, log, material_info, shader_deps)
except Exception as e: # one broken scene must not stop the others
log(f'FAIL scene {sid}: {e}')
return n
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
"""ENEMY_SPINES (or `ids`) from the enemy art bundles (each bundle loaded on its own; stops once every model was
found)."""
@@ -802,6 +841,9 @@ def main():
ap.add_argument('--manifest', default=str(ROOT / 'data/local-assets.json'))
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
ap.add_argument('--scenes', action='append', default=[], metavar='DIR',
help='an AB root holding the battlefield scene bundles of an in-season version (repeatable; '
'searched after --game and before .cache/official-scenes/*)')
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
ap.add_argument('--webp', action='store_true',
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
@@ -816,7 +858,10 @@ def main():
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived, 'webp': webp,
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES}},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES},
'scenes': {'sub': scenes.SCENE_SUB, 'tex': scenes.TEX_SUB,
'stages': [{'id': sid, 'bundle': scenes.scene_bundle(sid), 'deps': deps}
for sid, deps in scenes.SCENES]}},
ensure_ascii=False))
return 0
if args.webp:
@@ -829,7 +874,8 @@ def main():
jobs = select_jobs(args.only)
enemy_ids = spine_ids(args.only, ENEMY_SPINE_SUB, ENEMY_SPINES)
token_ids = spine_ids(args.only, TOKEN_SPINE_SUB, TOKEN_SPINES)
if not jobs and not enemy_ids and not token_ids:
stage_ids = scene_ids(args.only)
if not jobs and not enemy_ids and not token_ids and not stage_ids:
print(f'--only {args.only}: no job matches', file=sys.stderr)
return 2
out_root = Path(args.out)
@@ -842,6 +888,8 @@ def main():
total += export_enemy_spines(ab_root, out_root, manifest, print, enemy_ids)
if token_ids:
total += export_token_spines(ab_root, out_root, manifest, print, token_ids)
if stage_ids:
total += export_scenes(ab_root, out_root, manifest, print, stage_ids, scene_roots(ab_root, args.scenes))
old = {}
if args.only and Path(args.manifest).exists():
try:
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Download the 卫戍协议 battlefield scene bundles of an in-season version from the official asset CDN (optional).
The scene bundles (scenes.py) ship only while the season runs: an off-season client has none, and the current
hot-update list does not name them. The CDN still serves the versions that had them; this fetches the bundles of
scenes.SCENES (scene, lighting data, lighting settings) into .cache/official-scenes/<version>/<bundle path>, where
extract.py --only scene finds them. Every file is checked against the md5 of that version's hot-update list.
Usage:
python3 tools/local-extract/fetch_scenes.py [--version 26-04-13-12-31-42_e9b560] [--platform Android] [--out <dir>]
python3 tools/local-extract/fetch_scenes.py --list (what would be downloaded; reads the hot-update list only)
VERSION is the last version of 盟约·下半 that carried all eleven scenes (the `Data:` version of the game-data commits
of that season). Needs no dependencies. Everything is (c) Hypergryph; for private, non-commercial fan use only.
"""
import argparse
import hashlib
import io
import json
import sys
import urllib.request
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import scenes # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
VERSION = '26-04-13-12-31-42_e9b560'
PLATFORM = 'Android'
CDN = 'https://ak.hycdn.cn/assetbundle/official/{platform}/assets/{version}/'
def dat_name(bundle):
"""File name of a bundle on the CDN: the path without its extension, '/' → '_', '#' → '__', as .dat (a zip)."""
return bundle.rsplit('.', 1)[0].replace('/', '_').replace('#', '__') + '.dat'
def wanted(names, stage_ids):
"""The bundles of `names` (a hot-update list) that belong to the scenes of `stage_ids`, in list order."""
dirs = tuple(scenes.scene_bundle(sid).rsplit('/', 1)[0] + '/' for sid in stage_ids)
return [n for n in names if n.startswith(dirs)]
def get(url):
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=180) as r:
return r.read()
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--version', default=VERSION)
ap.add_argument('--platform', default=PLATFORM)
ap.add_argument('--out', help='output root (default .cache/official-scenes/<version>)')
ap.add_argument('--list', action='store_true', help='print the bundles and sizes, download nothing')
args = ap.parse_args()
base = CDN.format(platform=args.platform, version=args.version)
out = Path(args.out) if args.out else ROOT / '.cache' / 'official-scenes' / args.version
try:
infos = json.loads(get(base + 'hot_update_list.json'))['abInfos']
except Exception as e:
print(f'cannot read the hot-update list of {args.version} ({args.platform}): {e}', file=sys.stderr)
return 2
by_name = {x['name']: x for x in infos}
names = wanted(list(by_name), [sid for sid, _ in scenes.SCENES])
if not names:
print(f'version {args.version} lists no 卫戍协议 scene bundle (an off-season version?)', file=sys.stderr)
return 1
if args.list:
for n in names:
print(f'{by_name[n].get("totalSize", 0):>9} {n}')
print(f'{sum(by_name[n].get("totalSize", 0) for n in names):>9} total ({len(names)} bundles)')
return 0
bad = 0
for n in names:
dst = out / n
md5 = by_name[n].get('md5')
if dst.is_file() and hashlib.md5(dst.read_bytes()).hexdigest() == md5:
print(f'have {n}')
continue
try:
with zipfile.ZipFile(io.BytesIO(get(base + dat_name(n)))) as z:
blob = z.read(z.namelist()[0])
except Exception as e:
print(f'FAIL {n}: {e}', file=sys.stderr)
bad += 1
continue
if md5 and hashlib.md5(blob).hexdigest() != md5:
print(f'FAIL {n}: md5 mismatch', file=sys.stderr)
bad += 1
continue
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(blob)
print(f'got {n} ({len(blob)} bytes)')
print(f'done: {len(names) - bad}/{len(names)} bundles under {out}')
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())
+457
View File
@@ -0,0 +1,457 @@
"""The official battlefield scenes of 卫戍协议 (DESIGN §15): every battlefield is a prebuilt Unity scene
(scenes/activities/<season>/level_<stage>/level_<stage>.ab, with …/lightingdata.ab beside it) whose tile models are
baked into "Combined Mesh (root: scene)" objects, plus loose meshes for the parts the game shows and hides.
The scene bundles ship only while the season runs (the hot-update list of an off-season client has none), so they are
read from the client's AB root when it has them and else from a directory of bundles kept from an in-season version
(extract.py --scenes <dir>, default .cache/official-scenes/*; tools/local-extract/fetch_scenes.py fills it). Materials
and their textures come from the theme bundles of the local client (SCENES lists each scene's, as the official asset
index records them: the scene bundle's dependencies).
Output per stage, under <out>/scene/<stageId>/ (manifest group scene/<stageId>):
scene.json { version, stage, light, lightmaps, materials, groups, chunks: [{ material, lightmap, group, verts,
tris, offset, index32, bounds }] }
scene.bin the chunks' vertex data in board space (x = column, y = row, z = height: Unity's anchor-local
(x, y, −z)); per chunk: position f32×3, uv f32×2, lightmap uv f32×2 (scale / offset applied),
normal i8×4, colour u8×4, indices u16|u32 (padded to 4 bytes)
lm<i>.png lightmap page i (+ a WebP copy, the one the manifest lists): RGB = the baked indirect light (Lightmap-<i>_comp_light, stored RGBM: decode_rgbm),
A = the key light's baked shadow (Lightmap-<i>_comp_shadowmask, R)
and the materials' albedo textures under <out>/scene/tex/ (manifest group scene/tex, shared by the scenes).
A chunk's `group` is '' for what the scene shows at rest and the sub-graphic name (HIDDEN@1 …, MapSubGraphicHolder)
for the meshes the game switches on later.
"""
import json
import re
import struct
from pathlib import Path
SAND = ['arts/maps/map_autochesssand/res.ab', 'arts/maps/map_survivaloasis_city/res.ab', 'arts/maps/map_yumen/res.ab']
SHORE = ['arts/maps/map_doss_shore/res.ab']
# (stage id, theme bundles of the local client that hold the scene's materials / textures / shared meshes): the
# dependencies of each scene bundle in the official asset index (Android 25-11-21-15-21-44_ee1197 for 盟约·上半,
# 26-03-13-06-11-47_14fb99 and 26-04-13-12-31-42_e9b560 for 盟约·下半 — the same in all three).
SCENES = [
('act1autochess_m01', SAND),
('act1autochess_m02', SAND),
('act1autochess_m03', SAND),
('act1autochess_m04', SAND),
('act1autochess_m05', SAND + SHORE),
('act1autochess_m06', SAND),
('act1autochess_m07', SAND),
('act2autochess_m01', ['arts/maps/map_lakegarden/res.ab', 'arts/maps/map_syracuse_metro/res.ab',
'arts/maps/common/meshes/s_background_common.ab']),
('act2autochess_m02', SAND),
('act2autochess_m03', ['arts/maps/map_kxmr/res.ab', 'arts/maps/map_kxmr/background_mesh/bg_kxmr.ab',
'arts/maps/map_lm_center/res.ab']),
('act2autochess_m04', SAND + SHORE),
]
# bundles every scene may reference besides its theme (shared water / noise textures)
COMMON_DEPS = ['arts/maps/effect.ab']
SCENE_SUB = 'scene'
TEX_SUB = 'scene/tex'
SCENE_VERSION = 1
# linear light value that a lightmap page's 1.0 stands for (decode_rgbm; scene.json lightRange)
LIGHT_RANGE = 8.0
HIDDEN_NAME = re.compile(r'^[A-Z_]+@\d+$')
WATER_SHADERS = ('StylizedWater', 'StandardWater', 'DynamicWaterTile')
def scene_bundle(stage_id):
"""Bundle path of a stage's scene, relative to an AB root (act1autochess_m01 → scenes/activities/act1autochess/…)."""
season = stage_id.rsplit('_', 1)[0]
return f'scenes/activities/{season}/level_{stage_id}/level_{stage_id}.ab'
def find_scene(stage_id, roots):
"""The first existing scene bundle of `stage_id` under `roots` (AB roots), or None."""
rel = scene_bundle(stage_id)
for root in roots:
p = Path(root) / rel
if p.is_file():
return p
return None
# ---- small pure-Python linear algebra (row-major 3×4 affine matrices: [r0, r1, r2], each [a, b, c, t]) -----------
def trs(pos, rot, scale):
"""Affine matrix of a Unity local transform (position, quaternion xyzw, scale)."""
x, y, z, w = rot
r = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
return [[r[i][0] * scale[0], r[i][1] * scale[1], r[i][2] * scale[2], pos[i]] for i in range(3)]
def mul(a, b):
"""a · b of two affine matrices."""
return [[a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + (a[i][3] if j == 3 else 0.0) for j in range(4)]
for i in range(3)]
def det3(m):
return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
IDENTITY = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
def to_board(p, anchor):
"""Unity world position → board space (x = column, y = row, z = height above the low ground)."""
return (p[0] - anchor[0], p[1] - anchor[1], -(p[2] - anchor[2]))
def pack_chunk(ch):
"""Binary record of a chunk (see the module docstring); returns (bytes, index32)."""
n = len(ch['pos'])
flat = lambda rows: [v for row in rows for v in row] # noqa: E731
out = bytearray()
out += struct.pack(f'<{n * 3}f', *flat(ch['pos']))
out += struct.pack(f'<{n * 2}f', *flat(ch['uv']))
out += struct.pack(f'<{n * 2}f', *flat(ch['uv1']))
out += struct.pack(f'<{n * 4}b', *flat([(*(max(-127, min(127, int(round(c * 127)))) for c in nr), 0) for nr in ch['nrm']]))
out += struct.pack(f'<{n * 4}B', *flat([tuple(max(0, min(255, int(round(c * 255)))) for c in col) for col in ch['col']]))
idx32 = n > 65535
out += struct.pack(f'<{len(ch["idx"])}{"I" if idx32 else "H"}', *ch['idx'])
while len(out) % 4:
out.append(0)
return bytes(out), idx32
def decode_rgbm(img):
"""RGB image of a baked lightmap page stored RGBM (Unity, linear colour space: light = 5^2.2 · A^2.2 · RGB):
the linear light L as (L / LIGHT_RANGE)^(1/2.2) per channel — = (5^2.2 / LIGHT_RANGE)^(1/2.2) · A · RGB^(1/2.2),
clamped to 1 (the renderer reads L = value^2.2 · LIGHT_RANGE). A page without alpha is taken as plain RGB."""
from PIL import Image, ImageChops
if img.mode != 'RGBA':
return img.convert('RGB')
k = (5.0 ** 2.2 / LIGHT_RANGE) ** (1 / 2.2)
r, g, b, a = img.split()
m = a.point([min(255, int(round(v * k))) for v in range(256)])
gamma = [int(round(255 * (v / 255) ** (1 / 2.2))) for v in range(256)]
return Image.merge('RGB', tuple(ImageChops.multiply(ch.point(gamma), m) for ch in (r, g, b)))
def save_image(img, out_dir, stem, sub):
"""Write `img` as <stem>.png and a WebP copy beside it (lossy at quality 95 with the alpha lossless and the RGB
under transparent texels kept, like extract.py WEBP: every player downloads these with the scene); returns the
manifest entry, which lists the copy (the PNG with a Pillow that has no WebP)."""
img.save(Path(out_dir) / f'{stem}.png')
entry = {'path': f'/assets/local/{sub}/{stem}.png', 'w': img.width, 'h': img.height}
try:
img.save(Path(out_dir) / f'{stem}.webp', 'WEBP', quality=95, method=6, alpha_quality=100, exact=True)
entry.update(path=f'/assets/local/{sub}/{stem}.webp', webp='lossy')
except Exception:
pass
return entry
def hidden_groups(raw, objects):
"""{GameObject path id: sub-graphic name} of a MapSubGraphicHolder's raw data: after each name (HIDDEN@n) come the
GameObjects and MeshTileGraphics of that group as PPtrs of this file. The script has no type tree in the bundle,
so the fields are walked as 4-byte words: a length-prefixed name, or a (file 0, path id) pointer to an object."""
out, group, i = {}, None, 0
body = raw
while i + 4 <= len(body):
v = struct.unpack_from('<i', body, i)[0]
if 0 < v < 64 and i + 4 + v <= len(body):
text = body[i + 4:i + 4 + v]
if all(32 <= b < 127 for b in text):
s = text.decode('ascii')
if HIDDEN_NAME.match(s):
group = s
i += 4 + ((v + 3) & ~3)
continue
if v == 0 and i + 12 <= len(body) and group:
pid = struct.unpack_from('<q', body, i + 4)[0]
target = objects.get(pid)
if target is not None:
out[pid] = group
i += 12
continue
i += 4
return out
def export_scene(stage_id, deps, scene_path, ab_root, out_root, manifest, log, material_info, shader_deps=()):
"""Write one stage's scene (see the module docstring). Returns the number of files written (0 on failure)."""
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
from UnityPy.helpers.MeshHelper import MeshHandler
from PIL import Image
try:
env = UnityPy.load(str(scene_path))
except Exception as e: # corrupt or unsupported bundle
log(f'FAIL load {scene_path}: {e}')
return 0
objects = {o.path_id: o for o in env.objects}
scene_objs = list(objects.values())
light_bundle = scene_path.with_suffix('') / 'lightingdata.ab'
extra = [light_bundle] if light_bundle.is_file() else []
for dep in [*(Path(ab_root) / d for d in [*deps, *COMMON_DEPS]), *shader_deps]:
if Path(dep).is_file():
extra.append(Path(dep))
else:
log(f' warn {stage_id}: dependency {dep} missing (its materials are skipped)')
for dep in extra:
try:
env.load_file(str(dep))
except Exception as e:
log(f' warn {stage_id}: dependency {Path(dep).name}: {e}')
# ---- hierarchy: world matrices, active flags, hidden groups ---------------------------------------------------
tf = {} # transform path id → (local matrix, father id, game object)
for o in scene_objs:
if o.type.name != 'Transform':
continue
t = o.read()
p, r, s = t.m_LocalPosition, t.m_LocalRotation, t.m_LocalScale
tf[o.path_id] = (trs((p.x, p.y, p.z), (r.x, r.y, r.z, r.w), (s.x, s.y, s.z)), t.m_Father.m_PathID, t.m_GameObject)
world_cache = {}
def world(tid):
if tid not in tf:
return IDENTITY
if tid not in world_cache:
local, father, _ = tf[tid]
world_cache[tid] = mul(world(father), local) if father else local
return world_cache[tid]
go_of_tf = {tid: rec[2].m_PathID for tid, rec in tf.items()}
tf_of_go = {gid: tid for tid, gid in go_of_tf.items()}
go_info = {} # game object path id → (name, active)
for o in scene_objs:
if o.type.name == 'GameObject':
g = o.read()
go_info[o.path_id] = (g.m_Name, bool(g.m_IsActive))
hidden = {}
for o in scene_objs:
if o.type.name != 'MonoBehaviour':
continue
raw = o.get_raw_data()
if b'@' not in raw:
continue
for pid, group in hidden_groups(raw, objects).items():
target = objects[pid]
if target.type.name == 'GameObject':
hidden[pid] = group
elif target.type.name == 'MonoBehaviour':
try:
hidden[target.read().m_GameObject.m_PathID] = group
except Exception:
pass
def state(gid):
"""(active in the hierarchy, hidden group of the object or its nearest grouped ancestor)."""
active, group, tid = True, None, tf_of_go.get(gid)
while tid in tf:
g = go_of_tf[tid]
active = active and go_info.get(g, ('', True))[1]
if group is None and g in hidden:
group = hidden[g]
tid = tf[tid][1]
return active, group
anchor = (0.0, 0.0, 0.0)
for gid, (name, _) in go_info.items():
if name == 'Anchor' and gid in tf_of_go:
m = world(tf_of_go[gid])
anchor = (m[0][3], m[1][3], m[2][3])
break
# ---- light ----------------------------------------------------------------------------------------------------
light = None
for o in scene_objs:
if o.type.name != 'Light':
continue
d = o.read_typetree()
if d.get('m_Type') != 1: # directional
continue
m = world(tf_of_go.get(d['m_GameObject']['m_PathID']))
fwd = (m[0][2], m[1][2], m[2][2]) # the light shines along its +z
n = (fwd[0] ** 2 + fwd[1] ** 2 + fwd[2] ** 2) ** 0.5 or 1.0
c = d.get('m_Color', {})
light = {'dir': [round(-fwd[0] / n, 5), round(-fwd[1] / n, 5), round(fwd[2] / n, 5)],
'color': [round(c.get(k, 1.0), 5) for k in 'rgb'], 'intensity': round(d.get('m_Intensity', 1.0), 5)}
break
# ---- renderers → chunks -----------------------------------------------------------------------------------------
mesh_cache, materials, mat_tex = {}, {}, {}
chunks = {} # (material, lightmap, group) → {'pos': [], …}
def mesh_data(pptr):
key = (getattr(pptr, 'm_FileID', 0), pptr.m_PathID)
if key not in mesh_cache:
try:
h = MeshHandler(pptr.read())
h.process()
mesh_cache[key] = (h.m_Vertices, h.m_Normals, h.m_UV0, h.m_UV1, h.m_Colors, h.get_triangles())
except Exception as e:
log(f' warn {stage_id}: mesh #{pptr.m_PathID}: {e}')
mesh_cache[key] = None
return mesh_cache[key]
def material_name(pptr):
if not pptr.m_PathID:
return None
try:
mat = pptr.read()
except Exception: # its bundle is not in the client
return None
name = mat.m_Name
if name not in materials:
info = material_info(mat)
shader = info.get('shader') or ''
info['kind'] = 'water' if any(w in shader for w in WATER_SHADERS) else 'lit'
materials[name] = info
try:
for slot, env_ in mat.m_SavedProperties.m_TexEnvs:
if slot == '_MainTex' and env_.m_Texture.m_PathID:
mat_tex[name] = env_.m_Texture
except Exception:
pass
return name
skipped = 0
for o in scene_objs:
if o.type.name != 'MeshRenderer':
continue
r = o.read()
gid = r.m_GameObject.m_PathID
mf = None
for c in r.m_GameObject.read().m_Component:
if c.component.type.name == 'MeshFilter':
mf = c.component.read()
if mf is None or not mf.m_Mesh.m_PathID:
continue
data = mesh_data(mf.m_Mesh)
if not data:
continue
verts, normals, uv0, uv1, colors, tris = data
active, group = state(gid)
if not active and group is None:
group = 'inactive'
sb = r.m_StaticBatchInfo
static = sb.subMeshCount > 0
first = sb.firstSubMesh if static else 0
m = IDENTITY if static else world(tf_of_go.get(gid))
# board space mirrors Unity's z: the triangle order is reversed to stay counter-clockwise (three.js front
# faces), unless the object's own transform already mirrors it
flip = det3(m) > 0
so = r.m_LightmapTilingOffset
st = (1.0, 1.0, 0.0, 0.0) if static else (so.x, so.y, so.z, so.w)
lm = r.m_LightmapIndex if r.m_LightmapIndex < 65534 else -1
luv = uv1 if uv1 else uv0
for k, mref in enumerate(r.m_Materials):
sub = first + k
if sub >= len(tris):
break
name = material_name(mref)
if not name:
skipped += 1
continue
ch = chunks.setdefault((name, lm, group or ''), {'pos': [], 'uv': [], 'uv1': [], 'nrm': [], 'col': [], 'idx': [], 'map': None})
remap = {}
for tri in tris[sub]:
tri = (tri[0], tri[2], tri[1]) if flip else tri
for vi in tri:
j = remap.get(vi)
if j is None:
j = remap[vi] = len(ch['pos'])
v = verts[vi]
p = (m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2] + m[0][3],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2] + m[1][3],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] + m[2][3])
ch['pos'].append(to_board(p, anchor))
nv = normals[vi] if normals else (0.0, 0.0, -1.0)
nw = (m[0][0] * nv[0] + m[0][1] * nv[1] + m[0][2] * nv[2],
m[1][0] * nv[0] + m[1][1] * nv[1] + m[1][2] * nv[2],
m[2][0] * nv[0] + m[2][1] * nv[1] + m[2][2] * nv[2])
ln = (nw[0] ** 2 + nw[1] ** 2 + nw[2] ** 2) ** 0.5 or 1.0
ch['nrm'].append((nw[0] / ln, nw[1] / ln, -nw[2] / ln))
t0 = uv0[vi] if uv0 else (0.0, 0.0)
ch['uv'].append((t0[0], t0[1]))
t1 = luv[vi] if luv else (0.0, 0.0)
ch['uv1'].append((t1[0] * st[0] + st[2], t1[1] * st[1] + st[3]))
col = colors[vi] if colors else (1.0, 1.0, 1.0, 1.0)
ch['col'].append(tuple(col[:4]) if len(col) >= 4 else (*col[:3], 1.0))
ch['idx'].append(j)
if not chunks:
log(f' warn {stage_id}: no drawable mesh in {scene_path.name}')
return 0
# ---- write ------------------------------------------------------------------------------------------------------
out_dir = Path(out_root) / SCENE_SUB / stage_id
out_dir.mkdir(parents=True, exist_ok=True)
sub = f'{SCENE_SUB}/{stage_id}'
blob, records = bytearray(), []
for (name, lm, group), ch in sorted(chunks.items(), key=lambda kv: (kv[0][2], kv[0][0], kv[0][1])):
data, idx32 = pack_chunk(ch)
xs, ys, zs = zip(*ch['pos'])
records.append({'material': name, 'lightmap': lm, 'group': group, 'verts': len(ch['pos']), 'tris': len(ch['idx']) // 3,
'offset': len(blob), 'index32': idx32,
'bounds': [round(v, 3) for v in (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))]})
blob += data
n = 0
(out_dir / 'scene.bin').write_bytes(bytes(blob))
manifest.setdefault(sub, {})['bin'] = {'path': f'/assets/local/{sub}/scene.bin', 'kind': 'SceneData', 'bytes': len(blob)}
n += 1
# lightmap pages: light RGB + the shadow mask's R as alpha
pages = {}
for o in env.objects:
if o.type.name != 'Texture2D':
continue
try:
t = o.read()
except Exception:
continue
mm = re.match(r'^Lightmap-(\d+)_comp_(light|shadowmask)$', t.m_Name or '')
if mm:
pages.setdefault(int(mm.group(1)), {})[mm.group(2)] = t
used_pages = sorted({rec['lightmap'] for rec in records if rec['lightmap'] >= 0})
lightmaps = 0
for i in used_pages:
page = pages.get(i, {})
if 'light' not in page:
log(f' warn {stage_id}: lightmap {i} missing (drawn unlit)')
continue
try:
rgb = decode_rgbm(page['light'].image)
mask = page['shadowmask'].image.convert('RGB').getchannel('R').resize(rgb.size) if 'shadowmask' in page else Image.new('L', rgb.size, 255)
img = Image.merge('RGBA', (*rgb.split(), mask))
entry = save_image(img, out_dir, f'lm{i}', sub)
except Exception as e:
log(f' warn {stage_id}: lightmap {i}: {e}')
continue
manifest[sub][f'lm{i}'] = {**entry, 'kind': 'Lightmap'}
lightmaps = max(lightmaps, i + 1)
n += 1
# albedo textures of the materials (shared by the scenes)
tex_dir = Path(out_root) / TEX_SUB
tex_dir.mkdir(parents=True, exist_ok=True)
for name, pptr in sorted(mat_tex.items()):
try:
tex = pptr.read()
tname = re.sub(r'[^A-Za-z0-9_.\-\[\]]+', '_', tex.m_Name or f'tex_{pptr.m_PathID}')
materials[name]['map'] = tname
if tname in manifest.get(TEX_SUB, {}):
continue
manifest.setdefault(TEX_SUB, {})[tname] = {**save_image(tex.image, tex_dir, tname, TEX_SUB), 'kind': 'Texture2D'}
n += 1
except Exception as e:
log(f' warn {stage_id}: texture of {name}: {e}')
doc = {'version': SCENE_VERSION, 'stage': stage_id, 'bundle': scene_bundle(stage_id), 'light': light, 'lightmaps': lightmaps,
'lightRange': LIGHT_RANGE, 'materials': dict(sorted(materials.items())), 'groups': sorted({rec['group'] for rec in records}),
'chunks': records, 'binBytes': len(blob)}
(out_dir / 'scene.json').write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
manifest[sub]['scene'] = {'path': f'/assets/local/{sub}/scene.json', 'kind': 'Scene', 'chunks': len(records)}
n += 1
verts = sum(rec['verts'] for rec in records)
log(f'{scene_path.name}: {len(records)} chunks, {verts} vertices, {lightmaps} lightmaps -> {sub}'
+ (f' ({skipped} sub-meshes without a material)' if skipped else ''))
return n
+20 -3
View File
@@ -20,7 +20,9 @@
// THIRD-PARTY-NOTICES.md, README.md, CHANGELOG.md, docs/PLAYING.md and docs/DEPLOY.md. `npm ci --omit=dev` in the
// stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the
// files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/,
// data/local-assets.json) when present — nothing else on disk, so art the data no longer lists (焰狐龙梓兰, left out of
// data/local-assets.json) when present, including the 0.2.5 battlefield scenes under public/assets/local/scene/
// when this machine extracted them. Missing scenes are not an error: the board keeps the metal tiles, and the
// summary says so. Nothing else on disk ships, so art the data no longer lists (焰狐龙梓兰, left out of
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it. FULL_ZIP_JP_VOICE (below)
// decides whether the full zip carries the Japanese voice dub too (default: yes).
// Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …),
@@ -105,6 +107,8 @@ export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postin
export const FULL_ZIP_JP_VOICE = true;
/** Where the JP dub lies (plan.mjs voiceAlt: audio/voice/<lang>/<charId>/<file>). */
export const JP_VOICE_DIR = 'public/assets/audio/voice/jp/';
/** Extracted official battlefield scenes (0.2.5 asset pack). Already ignored with public/assets/. */
const SCENE_ART_PREFIX = 'public/assets/local/scene/';
/**
* Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and
@@ -220,9 +224,11 @@ export const manifestFiles = listedArtFiles;
* data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets
* that nothing lists (left out); `held`: listed files the zip holds back on purpose — the JP voice dub when `jpVoice`
* (FULL_ZIP_JP_VOICE) is off: neither shipped nor missing, and never deleted by an update.
* `scenes`: the files under public/assets/local/scene/ (the 0.2.5 battlefield scenes). They are also in `files`.
* A lite plan and a machine that has not extracted them return `scenes: []`; packaging still succeeds.
*/
export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}) {
if (lite) return { files: [], missing: [], orphans: [], held: [], local: false };
if (lite) return { files: [], missing: [], orphans: [], held: [], local: false, scenes: [] };
const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {});
const local = isFile(path.join(root, 'data', 'local-assets.json'));
if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f);
@@ -242,7 +248,11 @@ export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}
// without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles)
const lower = new Set([...files, ...held].map((f) => f.toLowerCase()));
const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase()));
return { files: [...files].sort(), missing: missing.sort(), orphans, held: held.sort(), local };
const sorted = [...files].sort();
return {
files: sorted, missing: missing.sort(), orphans, held: held.sort(), local,
scenes: sorted.filter((f) => f.startsWith(SCENE_ART_PREFIX)),
};
}
const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']];
@@ -401,6 +411,7 @@ export function plan(root, opts = {}) {
return {
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans,
held: art.held, localArt: art.local, problems, names: names.length, scanned,
scenes: { files: art.scenes || [], bytes: sumBytes(root, art.scenes || []) },
bytes: {
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
total: trackedBytes + artBytes + modulesBytes + vendorBytes,
@@ -419,6 +430,12 @@ export function formatSummary(p) {
];
if (!p.lite) {
lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`);
const sceneFiles = p.scenes?.files;
if (sceneFiles?.length) {
lines.push(`battlefield scenes: ${sceneFiles.length} files, ${MB(p.scenes.bytes || 0)} (0.2.5 asset pack, public/assets/local/scene; the metal board is the fallback when a stage has none)`);
} else if (p.scenes) {
lines.push('battlefield scenes: none on this machine (the full bundle ships the metal board; fetch_scenes.py then extract.py --only scene before packaging to include the 11 official scenes)');
}
}
if (p.generated?.length) lines.push(`generated: ${p.generated.join(', ')} (the pack index of the shipped packs, for a static host)`);
lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`);
+92 -3
View File
@@ -15,6 +15,11 @@
// bottle or PlayCover on macOS, or --game <dir>) with tools/local-extract/extract.py in a project-local Python
// venv (.venv-extract), then the board tile crops (tools/crop-board-atlas.mjs → tiles.json). Asked once on a
// terminal (the answer is remembered in .cache/setup-state.json; without a terminal it is skipped); never fatal.
// 7. Optional: the eleven official battlefield scenes (0.2.5 asset pack). The full release zip already contains
// them when the maintainer extracted them. A lite package or a source install may download the in-season bundles
// (tools/local-extract/fetch_scenes.py) and extract them (extract.py --only scene). Missing Python or
// UnityPy / lz4 / Pillow is a warning: the 3D board keeps the metal tiles. --scenes does it without asking;
// --no-scenes skips it. The answer is remembered like step 6.
//
// Options:
// --check report only, change nothing (exit 1 when something essential is missing)
@@ -22,8 +27,10 @@
// --asset-source=M direct (default) or mirror (opt-in; no public-IP lookup)
// SP_ASSET_SOURCE sets the default; SP_GITHUB_PROXY sets the HTTPS prefix (https://gh-proxy.com/)
// An empty SP_GITHUB_PROXY disables the proxy, including in mirror mode.
// --no-local skip the local-client detection and extraction
// --no-local skip the local-client detection and extraction (the scene step still runs unless --no-scenes)
// --local extract from the local client without asking (re-extracts when already done)
// --scenes download and extract the official battlefield scenes without asking
// --no-scenes do not download or extract battlefield scenes (the metal board stays)
// --game <dir> AssetBundle root of the local client (…/StreamingAssets/AB/Windows or PlayCover …/Documents/Bundles)
// -y, --yes answer "yes" to every question
// --quiet fewer lines (used by scripts/launch.mjs)
@@ -58,6 +65,7 @@ export const VENDOR_OPTIONAL = ['three.core.js', 'three.module.js'];
const STATE_FILE = path.join(ROOT, '.cache', 'setup-state.json');
export const VENV_DIR = path.join(ROOT, '.venv-extract');
const EXTRACT_PY = path.join(ROOT, 'tools', 'local-extract', 'extract.py');
const FETCH_SCENES_PY = path.join(ROOT, 'tools', 'local-extract', 'fetch_scenes.py');
const EXTRACT_REQ = path.join(ROOT, 'tools', 'local-extract', 'requirements.txt');
const LOCAL_MANIFEST = path.join(ROOT, 'data', 'local-assets.json');
const LOCAL_BOARD_ATLAS = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'TX_autochessi_D.png');
@@ -217,7 +225,7 @@ export function checkLocal() {
const has = (prefix) => !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith(prefix)));
return {
manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines: has('spine/enemy/'),
tokenSpines: has('spine/token/'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')),
tokenSpines: has('spine/token/'), scenes: has('scene/act'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')),
};
}
@@ -226,9 +234,47 @@ export function localGaps(local) {
const gaps = [];
if (local && !local.enemySpines) gaps.push('灼热/炽焰源石虫模型');
if (local && !local.tokenSpines) gaps.push('自选召唤物模型');
if (local && local.scenes === false) gaps.push('各战场官方场景');
return gaps.length ? `,缺少新版的${gaps.join('和')}` : '';
}
/**
* Whether setup should download and extract the official battlefield scenes (0.2.5 asset pack).
* The full release zip already contains them when the maintainer extracted them before packaging.
* A lite package or a source install may fetch the in-season bundles on this machine and extract them.
* Missing Python or UnityPy / lz4 / Pillow is not fatal: the 3D board keeps the metal tiles.
* `client` is true (autochess bundles present), 'incomplete' (a client dir without them) or false.
* @param {{ hasScenes?: boolean, client?: boolean|string, python?: boolean, deps?: boolean, check?: boolean, declined?: boolean, force?: boolean, yes?: boolean }} o
* @returns {{ run: boolean, state: 'ok'|'skip'|'warn'|'ask', detail: string }}
*/
export function sceneInstallPlan(o = {}) {
if (o.hasScenes && !o.force) {
return { run: false, state: 'ok', detail: '已提取各战场官方场景(0.2.5 素材;某个战场没有文件时仍用金属地块)' };
}
if (o.check) {
return { run: false, state: 'skip', detail: '未提取各战场官方场景:3D 棋盘用金属地块(node tools/setup.mjs --scenes 可在本机下载并提取)' };
}
if (o.client === 'incomplete') {
return { run: false, state: 'warn', detail: '客户端缺少卫戍协议资源,战场场景未提取。3D 棋盘继续用金属地块' };
}
if (!o.client) {
return { run: false, state: 'skip', detail: '没有本机客户端,不能提取战场场景(贴图仍取自客户端)。3D 棋盘用金属地块;完整包已带场景时可复制 public/assets/local 和 data/local-assets.json' };
}
if (!o.python) {
return { run: false, state: 'warn', detail: '没有 Python 3.8+,战场场景未提取。3D 棋盘继续用金属地块' };
}
if (o.deps === false) {
return { run: false, state: 'warn', detail: '提取依赖(UnityPy / lz4 / Pillow)没装好,战场场景未提取。3D 棋盘继续用金属地块' };
}
if (o.declined && !o.force && !o.yes) {
return { run: false, state: 'skip', detail: '已跳过战场场景(node tools/setup.mjs --scenes)。3D 棋盘用金属地块' };
}
if (!o.force && !o.yes) {
return { run: false, state: 'ask', detail: '可以下载赛季场景包并提取(约 23 MB)。失败或跳过时 3D 棋盘仍用金属地块' };
}
return { run: true, state: 'ok', detail: '将下载赛季场景包并提取;失败时 3D 棋盘仍用金属地块' };
}
/**
* Board tile crops of the extracted atlas (tools/crop-board-atlas.mjs → tiles.json, read by the 2D and 3D board
* renderers; without it the client requests a missing file and keeps the procedural tiles). Output only on failure.
@@ -347,7 +393,7 @@ function ensureVenv(py, log) {
// ---------------------------------------------------------------------------------------------------
function parseArgs(argv) {
const o = { check: false, assets: true, local: 'ask', game: null, yes: false, quiet: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
const o = { check: false, assets: true, local: 'ask', scenes: 'ask', game: null, yes: false, quiet: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--check') o.check = true;
@@ -356,6 +402,8 @@ function parseArgs(argv) {
else if (a.startsWith('--asset-source=')) o.source = a.slice('--asset-source='.length);
else if (a === '--no-local') o.local = 'no';
else if (a === '--local') o.local = 'force';
else if (a === '--no-scenes') o.scenes = 'no';
else if (a === '--scenes') o.scenes = 'force';
else if (a === '--game') { o.game = argv[++i] || null; if (o.local !== 'no') o.local = 'force'; }
else if (a.startsWith('--game=')) { o.game = a.slice(7) || null; if (o.local !== 'no') o.local = 'force'; }
else if (a === '-y' || a === '--yes') o.yes = true;
@@ -497,6 +545,47 @@ async function main() {
}
}
// 7. official battlefield scenes (optional; the full zip ships them, a lite / source install may fetch them)
if (opts.scenes !== 'no') {
const sceneLocal = checkLocal();
const sceneClient = findClient(opts.game);
const scenePy = findPython();
const clientFlag = sceneClient?.autochess ? true : (sceneClient ? 'incomplete' : false);
let decision = sceneInstallPlan({
hasScenes: sceneLocal.scenes, client: clientFlag, python: !!scenePy, check: opts.check,
declined: !!loadState().sceneDeclined, force: opts.scenes === 'force', yes: opts.yes,
});
if (decision.state === 'ask') {
log(`\n${c.cyan('▶')} 各战场的官方场景还没提取(11 个战场;赛季场景包约 23 MB,贴图仍取自本机客户端)。`);
log(' 下载并提取后,3D 棋盘按战场显示官方场景。没有提取依赖、下载失败或跳过时,仍用现在的金属地块。');
const answer = await ask('现在下载并提取吗?', true);
if (answer === null) decision = { run: false, state: 'skip', detail: '无终端,未询问、未提取战场场景。3D 棋盘用金属地块(需要时 node tools/setup.mjs --scenes)' };
else if (!answer) {
saveState({ sceneDeclined: true });
decision = { run: false, state: 'skip', detail: '已跳过战场场景(node tools/setup.mjs --scenes)。3D 棋盘用金属地块' };
} else decision = { run: true, state: 'ok', detail: decision.detail };
}
if (!decision.run) add(decision.state === 'ok' ? 'ok' : decision.state, '各战场官方场景(可选)', decision.detail);
else {
const venv = ensureVenv(scenePy, log);
if (!venv.ok) add('warn', '各战场官方场景(可选)', `${venv.why}。3D 棋盘继续用金属地块`);
else {
log(` ${c.cyan('▶')} 下载赛季场景包(python tools/local-extract/fetch_scenes.py,约 23 MB)`);
const fr = run(venv.python, [FETCH_SCENES_PY], { env: PY_ENV });
if (!fr.ok) add('warn', '各战场官方场景(可选)', `场景包下载未完成(退出码 ${fr.code})。3D 棋盘继续用金属地块;稍后可重试 node tools/setup.mjs --scenes`);
else {
log(` ${c.cyan('▶')} 提取战场场景(extract.py --only scene)`);
const er = run(venv.python, [EXTRACT_PY, '--game', sceneClient.path, '--only', 'scene'], { env: PY_ENV });
const afterScenes = checkLocal();
if (er.ok && afterScenes.scenes) {
add('ok', '各战场官方场景(可选)', '已提取;进入对局时按战场显示,没有的战场仍用金属地块');
saveState({ sceneDeclined: false });
} else add('warn', '各战场官方场景(可选)', `提取未成功(退出码 ${er.code})。3D 棋盘继续用金属地块`);
}
}
}
}
// summary
log(c.bold('\n── 准备情况 ──────────────────────────────'));
const width = Math.max(...summary.map((s) => displayWidth(s.label))) + 2;