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:
1 parent
7fd0478dc0
commit
d482fbb8ba
19 files changed
+1659
-22
No files matched your search
+1
-1
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user