同步主站 Cocos 当前工程:关卡资源、运行脚本与打包工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-12 14:40:34 +08:00
parent ae75fce80b
commit fac515a669
5609 changed files with 27744634 additions and 5653 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,8 @@ usage() {
与 CDN 使用同一打包逻辑package-for-cdn目录结构一致
Build/mstest5.loader.js
StreamingAssets/aa/WebGL/*.bundle
levels-database.json(.br)
StreamingAssets/aa/WebGL/*.bundle.br
levels-db-index.json(.br)
区别仅在于加载方式:本地从 /unity/ 读取CDN 从 unitycdndir 读取。

View File

@@ -198,7 +198,7 @@ echo ">>> [3/3] 生成 CDN 上传清单: $MANIFEST"
echo "cdn: $CDN_BASE/StreamingAssets/$rel"
echo ""
done < <(find "$PACK_DIR/StreamingAssets" -type f ! -name '.DS_Store' -print0 | sort -z)
for db in levels-database.json levels-database.json.br; do
for db in levels-db-index.json levels-db-index.json.br levels-database.json levels-database.json.br; do
if [[ -f "$PACK_DIR/$db" ]]; then
echo "local: $db"
echo "cdn: $CDN_BASE/$db"
@@ -220,7 +220,7 @@ catalog_path, cdn = sys.argv[1], sys.argv[2].rstrip('/')
d = json.load(open(catalog_path))
names = sorted({x.split('/')[-1] for x in d.get('m_InternalIds', []) if '.bundle' in x})
for n in names:
print(f'curl -I "{cdn}/StreamingAssets/aa/WebGL/{n}"')
print(f'curl -I "{cdn}/StreamingAssets/aa/WebGL/{n}.br"')
PY
} > "$MANIFEST"

View File

@@ -106,10 +106,9 @@ def merge_prefab_maps(L: dict, prefab_dir: Path) -> None:
L["_mapSource"] = "boundary_ring"
return
maps = parse_level_prefab(p)
if maps.get("ground"):
L["ground"] = maps["ground"]
if maps.get("border"):
L["border"] = maps["border"]
# 空边框也要写回,避免旧 JSON 里的 WallBlock 残留
L["ground"] = maps.get("ground") or {}
L["border"] = maps.get("border") or {}
L["_mapSource"] = "unity_prefab"
@@ -123,10 +122,8 @@ def strip_internal(L: dict) -> dict:
out["unityPrefab"] = L["unityPrefab"]
if L.get("cocosPrefab"):
out["cocosPrefab"] = L["cocosPrefab"]
if L.get("ground"):
out["ground"] = L["ground"]
if L.get("border"):
out["border"] = L["border"]
out["ground"] = L.get("ground") or {}
out["border"] = L.get("border") or {}
if L.get("theme"):
out["theme"] = L["theme"]
return out

View File

@@ -65,18 +65,12 @@ def prop_placement_from_path(path: str) -> str | None:
def infer_theme_from_level(level_id: int, spawn_paths: list[str]) -> str:
"""按主站关卡批次分配主题道具路径可覆盖snow/sanxing"""
if 91601 <= level_id <= 91900:
if 91601 <= level_id <= 92200:
return "silu"
if 91901 <= level_id <= 92200:
return "redarmy"
if 92201 <= level_id <= 92500:
return "numMan"
if 92501 <= level_id <= 92800:
if 92201 <= level_id <= 92800:
return "chinese"
if 92801 <= level_id <= 93100:
return "snow"
if 93101 <= level_id <= 93700:
return "sanxing"
if 92801 <= level_id <= 94000:
return "numMan"
joined = " ".join(spawn_paths).lower()
if "prop_sanxing" in joined or "nprop_sanxing" in joined:
return "sanxing"

View File

@@ -1,10 +1,8 @@
#!/usr/bin/env python3
"""从 Unity Level{N}.prefab 解析 TilemapGround / Border格子数据。
层级锁定:
- Baseblock / JumpBlock → ground地砖属性
- WallBlock / 墙饰 → border墙砖属性
不再用死板的 tileIndex==1 → JumpBlock。
贴图以 Tilemap 的 m_TileSpriteArray 为准(和 Unity 编辑器里看到的砖一致),
不按关卡主题改名、不换层、不把 JumpBlock 收成 Baseblock。
"""
from __future__ import annotations
@@ -24,22 +22,45 @@ BORDER_DECOR = frozenset(
}
)
UNITY_FOLDER_TO_COCOS = {
"Chinese": "chinese",
"chinese": "chinese",
"RedArmy": "redArmy",
"redArmy": "redArmy",
"redarmy": "redArmy",
"numMan": "numMan",
"sanxing": "sanxing",
"snow": "snow",
"silu": "silu",
"SILU": "silu",
}
def tile_basename(name: str | None) -> str:
if not name:
return ""
return name.rsplit("/", 1)[-1]
def parse_tilemap_layer(text: str, layer_name: str) -> dict[str, int]:
"""返回格子 → 该 Tilemap 本地 m_TileIndex。"""
parts = re.split(r"(?=--- !u!1 &)", text)
for part in parts:
if f"m_Name: {layer_name}" not in part or "Tilemap:" not in part:
continue
tiles: dict[str, int] = {}
for m in re.finditer(
r"first: \{x: (-?\d+), y: (-?\d+), z: 0\}[\s\S]*?m_TileIndex: (\d+)",
part,
):
x, y, ti = int(m.group(1)), int(m.group(2)), int(m.group(3))
tiles[f"{x},{y}"] = ti
return tiles
return {}
cells = parse_tilemap_cells(text, layer_name)
return {k: v[0] for k, v in cells.items()}
def parse_tilemap_cells(text: str, layer_name: str) -> dict[str, tuple[int, int]]:
"""格子 → (m_TileIndex, m_TileSpriteIndex)。"""
part = _tilemap_part(text, layer_name)
if not part:
return {}
tiles: dict[str, tuple[int, int]] = {}
for m in re.finditer(
r"first: \{x: (-?\d+), y: (-?\d+), z: 0\}[\s\S]*?m_TileIndex: (\d+)[\s\S]*?m_TileSpriteIndex: (\d+)",
part,
):
x, y = int(m.group(1)), int(m.group(2))
tiles[f"{x},{y}"] = (int(m.group(3)), int(m.group(4)))
return tiles
def _tilemap_part(text: str, layer_name: str) -> str | None:
@@ -51,15 +72,14 @@ def _tilemap_part(text: str, layer_name: str) -> str | None:
return None
def _tile_asset_guids(tilemap_part: str) -> list[str | None]:
"""按 m_TileAssetArray 顺序解析 guid空槽为 None"""
def _guid_array(tilemap_part: str, header: str, stop_tokens: tuple[str, ...]) -> list[str | None]:
assets: list[str | None] = []
in_arr = False
for line in tilemap_part.splitlines():
if "m_TileAssetArray:" in line:
if header in line:
in_arr = True
continue
if in_arr and ("m_TileSpriteArray" in line or "m_AnimationFrameRate" in line):
if in_arr and any(tok in line for tok in stop_tokens):
break
if not in_arr:
continue
@@ -71,20 +91,57 @@ def _tile_asset_guids(tilemap_part: str) -> list[str | None]:
return assets
def _tile_asset_guids(tilemap_part: str) -> list[str | None]:
"""按 m_TileAssetArray 顺序解析 guid空槽为 None"""
return _guid_array(
tilemap_part,
"m_TileAssetArray:",
("m_TileSpriteArray", "m_AnimationFrameRate"),
)
def _tile_sprite_guids(tilemap_part: str) -> list[str | None]:
return _guid_array(
tilemap_part,
"m_TileSpriteArray:",
("m_TileMatrixArray", "m_TileColorArray", "m_AnimationFrameRate"),
)
def _cocos_folder(unity_folder: str) -> str:
if unity_folder in UNITY_FOLDER_TO_COCOS:
return UNITY_FOLDER_TO_COCOS[unity_folder]
return unity_folder[:1].lower() + unity_folder[1:] if unity_folder else ""
@lru_cache(maxsize=1)
def _guid_to_tile_name(unity_texture_root: str) -> dict[str, str]:
"""guid → Cocos 贴图引用,如 numMan/Baseblock。"""
root = Path(unity_texture_root)
out: dict[str, str] = {}
if not root.is_dir():
return out
for meta in root.rglob("*.asset.meta"):
for meta in root.rglob("*.meta"):
name = meta.name
if not (name.endswith(".png.meta") or name.endswith(".asset.meta")):
continue
try:
text = meta.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
m = re.search(r"^guid: ([a-f0-9]+)", text, re.M)
if m:
out[m.group(1)] = meta.name.replace(".asset.meta", "")
if not m:
continue
stem = name.replace(".png.meta", "").replace(".asset.meta", "")
try:
rel = meta.relative_to(root)
except ValueError:
out[m.group(1)] = stem
continue
if len(rel.parts) >= 2:
out[m.group(1)] = f"{_cocos_folder(rel.parts[0])}/{stem}"
else:
out[m.group(1)] = stem
return out
@@ -94,22 +151,32 @@ def tile_asset_names(text: str, layer_name: str, unity_texture_root: Path | None
return []
guids = _tile_asset_guids(part)
if unity_texture_root is None:
# 默认prefab 所在工程 Assets/Texture
return [None] * len(guids)
mapping = _guid_to_tile_name(str(unity_texture_root))
return [mapping.get(g) if g else None for g in guids]
def tile_sprite_names(text: str, layer_name: str, unity_texture_root: Path | None = None) -> list[str | None]:
part = _tilemap_part(text, layer_name)
if not part:
return []
guids = _tile_sprite_guids(part)
if unity_texture_root is None:
return [None] * len(guids)
mapping = _guid_to_tile_name(str(unity_texture_root))
return [mapping.get(g) if g else None for g in guids]
def normalize_ground_tile(name: str | None) -> str:
return "JumpBlock" if name == "JumpBlock" else "Baseblock"
return "JumpBlock" if tile_basename(name) == "JumpBlock" else "Baseblock"
def normalize_border_tile(name: str | None) -> str:
if not name or name in GROUND_TILES:
base = tile_basename(name)
if not name or base in GROUND_TILES:
return "WallBlock"
if name in BORDER_DECOR:
if base in BORDER_DECOR:
return name
# 其它墙饰保留文件名,禁止地砖名漏到墙层
return name
@@ -120,16 +187,16 @@ def _resolve_name(assets: list[str | None], tile_index: int) -> str | None:
def parse_level_prefab(prefab_path: Path, unity_texture_root: Path | None = None) -> dict:
"""按 Unity 原层导入:只保留有 Tile Asset 的格子,空槽不补砖。"""
if not prefab_path.is_file():
return {}
text = prefab_path.read_text(encoding="utf-8")
g_raw = parse_tilemap_layer(text, "Ground")
b_raw = parse_tilemap_layer(text, "Border")
if not g_raw and not b_raw:
g_cells = parse_tilemap_cells(text, "Ground")
b_cells = parse_tilemap_cells(text, "Border")
if not g_cells and not b_cells:
return {}
if unity_texture_root is None:
# Level91601.prefab → …/Assets/Prefabs/Level → …/Assets/Texture
unity_texture_root = prefab_path.resolve().parents[2] / "Texture"
g_assets = tile_asset_names(text, "Ground", unity_texture_root)
@@ -138,24 +205,21 @@ def parse_level_prefab(prefab_path: Path, unity_texture_root: Path | None = None
ground: dict[str, str] = {}
border: dict[str, str] = {}
# 按瓦片类型归层层级固定地砖→Ground墙/饰→Border
for key, ti in g_raw.items():
name = _resolve_name(g_assets, ti)
if name in GROUND_TILES:
ground[key] = normalize_ground_tile(name)
else:
border[key] = normalize_border_tile(name)
for key, (ti, _si) in g_cells.items():
asset = _resolve_name(g_assets, ti)
if not asset:
continue
base = tile_basename(asset)
ground[key] = "JumpBlock" if base == "JumpBlock" else base if base in GROUND_TILES else "Baseblock"
for key, ti in b_raw.items():
name = _resolve_name(b_assets, ti)
if name in GROUND_TILES:
ground[key] = normalize_ground_tile(name)
else:
border[key] = normalize_border_tile(name)
# 同格冲突:墙优先(不可走)
for key in list(ground.keys()):
if key in border:
del ground[key]
for key, (ti, _si) in b_cells.items():
asset = _resolve_name(b_assets, ti)
if not asset:
# Unity 空槽不渲染,禁止补 WallBlock否则空隙里会多出砖
continue
base = tile_basename(asset)
if base in GROUND_TILES:
continue
border[key] = base if base else "WallBlock"
return {"ground": ground, "border": border}

View File

@@ -238,6 +238,22 @@ HUD_GLOBS = (
)
def upscale_hud_png(path: Path, scale: int = 4) -> None:
"""把低分辨率 HUD 圆钮放大,避免 100px 原图在 HUD/预览里发糊。"""
try:
from PIL import Image, ImageFilter, ImageEnhance
except ImportError:
return
im = Image.open(path).convert("RGBA")
w, h = im.size
if w >= 300 and h >= 300:
return
hd = im.resize((w * scale, h * scale), Image.Resampling.LANCZOS)
hd = hd.filter(ImageFilter.UnsharpMask(radius=1.8, percent=140, threshold=1))
hd = ImageEnhance.Contrast(hd).enhance(1.04)
hd.save(path, format="PNG", optimize=True)
def copy_hud_assets(unity_tex: Path, out_root: Path, style_key: str, rel: str) -> int:
"""拷贝 Unity UI 按钮贴图(原先 rglob 会跳过 anniu_/倍速/声音)"""
src = unity_tex if rel == "." else unity_tex / rel
@@ -257,6 +273,8 @@ def copy_hud_assets(unity_tex: Path, out_root: Path, style_key: str, rel: str) -
seen.add(key)
target = dst / png.name
shutil.copy2(png, target)
if style_key == "numMan" and png.name in ("anniu_03.png", "anniu_17.png", "anniu_19.png"):
upscale_hud_png(target)
count += 1
return count
@@ -303,6 +321,18 @@ def copy_theme(unity_tex: Path, out_root: Path, style_key: str, rel: str) -> int
return count
def copy_direction_gizmo(unity_tex: Path, out_root: Path) -> int:
"""生成灰色等距罗盘(不直接拷 Unity 像素图)。"""
import sys
tools_dir = Path(__file__).resolve().parent
if str(tools_dir) not in sys.path:
sys.path.insert(0, str(tools_dir))
from make_direction_gizmo import write as write_direction_gizmo
dst = out_root / "ui" / "ui-direction.png"
write_direction_gizmo(dst)
return 1
def copy_prop(unity_tex: Path, out_root: Path) -> int:
src = unity_tex / "Prop"
if not src.is_dir():
@@ -347,6 +377,11 @@ def main():
print(f" prop: {pn} png")
total += pn
dn = copy_direction_gizmo(unity_tex, out_root)
if dn:
print(f" ui-direction: {dn} png")
total += dn
print(f"Imported {total} files -> {out_root}")
print("请在 Cocos Creator 中刷新 assets/resources/textures然后运行:")
print(" python3 tools/fix_tile_texture_metas.py")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""生成关卡左下角等距 X/Y 罗盘(灰箭头 + 青色标注,抗锯齿)。"""
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
# HUD 显示约 1.55× Unity2x 出图保证清晰
OUT_W, OUT_H = 802, 430
SS = 4
ARROW = (92, 98, 108, 255)
ARROW_EDGE = (58, 62, 70, 255)
TEAL = (64, 220, 188, 255)
LABEL_STROKE = (32, 36, 40, 230)
SHADOW = (0, 0, 0, 48)
def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for fp in (
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/Library/Fonts/Arial Bold.ttf",
):
try:
return ImageFont.truetype(fp, size)
except OSError:
continue
return ImageFont.load_default()
def _arrow(draw: ImageDraw.ImageDraw, ox: float, oy: float, angle: float, length: float) -> tuple[float, float]:
"""沿 angle屏幕 Y 向上)画一支圆润箭头,返回箭头顶点。"""
dx, dy = math.cos(angle), -math.sin(angle)
px, py = -dy, dx
shaft = 12.5 * SS
head_len = 26 * SS
head_w = 20 * SS
tip_x = ox + dx * length
tip_y = oy + dy * length
base_x = ox + dx * (length - head_len)
base_y = oy + dy * (length - head_len)
# 箭身:圆头线段
draw.line([(ox, oy), (base_x, base_y)], fill=ARROW_EDGE, width=int(shaft + 2.4 * SS), joint="curve")
draw.line([(ox, oy), (base_x, base_y)], fill=ARROW, width=int(shaft), joint="curve")
hx, hy = px * head_w, py * head_w
head = [
(tip_x, tip_y),
(base_x + hx, base_y + hy),
(base_x - hx, base_y - hy),
]
draw.polygon(head, fill=ARROW_EDGE)
inset = 1.15 * SS
inner = [
(tip_x - dx * inset * 0.4, tip_y - dy * inset * 0.4),
(base_x + hx * 0.78 + dx * inset, base_y + hy * 0.78 + dy * inset),
(base_x - hx * 0.78 + dx * inset, base_y - hy * 0.78 + dy * inset),
]
draw.polygon(inner, fill=ARROW)
return tip_x, tip_y
def render() -> Image.Image:
w, h = OUT_W * SS, OUT_H * SS
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
ox = w * 0.50
oy = h * 0.84
length = min(w, h) * 0.46
ang_x = math.atan2(0.5, 1.0) # 2:1 等距 +X → 右上
ang_y = math.atan2(0.5, -1.0) # +Y → 左上
tx, ty = _arrow(draw, ox, oy, ang_x, length)
uy, vy = _arrow(draw, ox, oy, ang_y, length)
r = 11 * SS
draw.ellipse((ox - r - SS, oy - r - SS, ox + r + SS, oy + r + SS), fill=ARROW_EDGE)
draw.ellipse((ox - r, oy - r, ox + r, oy + r), fill=ARROW)
ir = 5.2 * SS
draw.ellipse((ox - ir, oy - ir, ox + ir, oy + ir), fill=TEAL)
font = _font(int(60 * SS))
labels = (("Y", uy, vy, ang_y), ("X", tx, ty, ang_x))
stroke = max(2, int(3.6 * SS))
for text, px, py, ang in labels:
dx, dy = math.cos(ang), -math.sin(ang)
lx = px + dx * 30 * SS
ly = py + dy * 30 * SS
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
pos = (lx - tw / 2, ly - th / 2 - 6 * SS)
for oxs in range(-stroke, stroke + 1):
for oys in range(-stroke, stroke + 1):
if oxs * oxs + oys * oys > stroke * stroke:
continue
draw.text((pos[0] + oxs, pos[1] + oys), text, font=font, fill=LABEL_STROKE)
draw.text(pos, text, font=font, fill=TEAL)
alpha = layer.split()[3]
sh = Image.new("RGBA", (w, h), (0, 0, 0, 0))
sh_draw = ImageDraw.Draw(sh)
# 用同一 alpha 做淡阴影
sh.putalpha(alpha.point(lambda a: min(255, int(a * 0.28)) if a else 0))
rgb = Image.new("RGB", (w, h), (0, 0, 0))
sh = Image.merge("RGBA", (*rgb.split(), sh.split()[-1]))
sh = sh.filter(ImageFilter.GaussianBlur(radius=1.6 * SS))
composed = Image.new("RGBA", (w, h), (0, 0, 0, 0))
composed.alpha_composite(sh, (int(1.2 * SS), int(1.4 * SS)))
composed.alpha_composite(layer)
out = composed.resize((OUT_W, OUT_H), Image.Resampling.LANCZOS)
alpha = out.split()[3].point(lambda p: 255 if p >= 16 else 0)
box = alpha.getbbox()
if box:
pad = 16
x0 = max(0, box[0] - pad)
y0 = max(0, box[1] - pad)
x1 = min(out.width, box[2] + pad)
y1 = min(out.height, box[3] + pad)
out = out.crop((x0, y0, x1, y1))
return out
def write(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
render().save(path, format="PNG", optimize=True)
return path
if __name__ == "__main__":
out = Path(__file__).resolve().parents[1] / "assets/resources/textures/ui/ui-direction.png"
write(out)
print(f"wrote {out}")

View File

@@ -17,6 +17,8 @@ const {
minifyLevelsDatabase,
brotliCompressFile,
brotliCompressWebglBundles,
writeSlimCatalog,
stripPlainIfBrotli,
formatBytes,
} = require('./package-optimize');
const { listRuntimeFiles, assertRuntimePack } = require('./runtime-pack');
@@ -88,11 +90,16 @@ function attachLevelsDatabase(outDir) {
}
splitLevelsDatabase(levelsDbSrc, outDir);
const levelsDbDst = path.join(outDir, 'levels-database.json');
const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst);
console.log(`>>> levels-database.json (legacy 回退): ${formatBytes(before)}${formatBytes(after)}`);
const { raw, br } = brotliCompressFile(levelsDbDst, path.join(outDir, 'levels-database.json.br'));
console.log(`>>> levels-database.json.br: ${formatBytes(raw)}${formatBytes(br)}`);
// 运行时只走分片;全量库仅 KEEP_LEGACY_DB=1 保留(约 6 MB
if (process.env.KEEP_LEGACY_DB === '1') {
const levelsDbDst = path.join(outDir, 'levels-database.json');
const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst);
console.log(`>>> levels-database.json (legacy 回退): ${formatBytes(before)}${formatBytes(after)}`);
const { raw, br } = brotliCompressFile(levelsDbDst, path.join(outDir, 'levels-database.json.br'));
console.log(`>>> levels-database.json.br: ${formatBytes(raw)}${formatBytes(br)}`);
} else {
console.log('>>> 跳过全量 levels-database.json运行时用分片KEEP_LEGACY_DB=1 可保留)');
}
}
if (!buildDir || !outDir) {
@@ -282,6 +289,11 @@ copyDir(path.join(unityRef, 'StreamingAssets/aa/AddressablesLink'), path.join(aa
const BUNDLE = parseCatalogBundles(path.join(aaDir, 'catalog.json'));
console.log('>>> catalog bundles:', BUNDLE.all.join(', '));
const slimBytes = writeSlimCatalog(
path.join(aaDir, 'catalog.json'),
[BUNDLE.shaders, BUNDLE.assetsAll, BUNDLE.scenesAll].filter(Boolean),
);
console.log(`>>> catalog.json 已精简为 ${formatBytes(slimBytes)}(仅 bundle 名)`);
if (BUNDLE.shaders) {
copyFile(
path.join(unityRef, 'StreamingAssets/aa/WebGL', BUNDLE.shaders),
@@ -361,7 +373,7 @@ console.log(`>>> assets_core (assets_all): ${formatBytes(fs.statSync(assetsCoreZ
const assetsBr = brotliCompressFile(assetsCoreZip, assetsCoreZip + '.br');
console.log(`>>> ${BUNDLE.assetsAll}.br: ${formatBytes(assetsBr.raw)}${formatBytes(assetsBr.br)}`);
// —— 3b. 每关独立 bundle + levels-manifest.json ——
// —— 3b. 每 15 关一包 + levels-manifest.json ——
const levelPrefabsSrc = path.join(buildDir, 'assets', 'level-prefabs');
let levelPackStats = null;
if (!MERGE_LEVELS) {
@@ -375,10 +387,26 @@ if (!MERGE_LEVELS) {
levelPackStats = splitLevelBundles(levelPrefabsSrc, webglDir, manifestPath, tmp);
const brStats = brotliCompressWebglBundles(webglDir);
console.log(`>>> bundle Brotli: ${brStats.count} 个, 节省 ${formatBytes(brStats.saved)}`);
if (fs.existsSync(manifestPath)) {
const manBr = brotliCompressFile(manifestPath, manifestPath + '.br');
console.log(`>>> levels-manifest.json.br: ${formatBytes(manBr.raw)}${formatBytes(manBr.br)}`);
}
}
attachLevelsDatabase(outDir);
const catalogPath = path.join(aaDir, 'catalog.json');
if (fs.existsSync(catalogPath)) {
brotliCompressFile(catalogPath, catalogPath + '.br');
}
const stripped = stripPlainIfBrotli(outDir);
if (stripped.skipped) {
console.log('>>> KEEP_PLAIN=1: 保留未压缩 .bundle / .json');
} else {
console.log(`>>> 去掉已有 .br 的原文: ${stripped.removed} 个, 节省 ${formatBytes(stripped.saved)}`);
}
// —— 4. Build/ 仅 4 个文件(与 Unity 同名)——
const buildOut = path.join(outDir, 'Build');
mkdirp(buildOut);
@@ -434,7 +462,10 @@ const requiredBundles = [
];
if (BUNDLE.shaders) requiredBundles.push(path.join(webglDir, BUNDLE.shaders));
for (const p of requiredBundles) {
if (!fs.existsSync(p) || fs.statSync(p).size < 100) {
const br = `${p}.br`;
const ok = (fs.existsSync(p) && fs.statSync(p).size >= 100)
|| (fs.existsSync(br) && fs.statSync(br).size >= 50);
if (!ok) {
console.error('>>> bundle 无效:', p);
process.exit(1);
}
@@ -442,6 +473,6 @@ for (const p of requiredBundles) {
const preloadNote = MERGE_LEVELS
? 'scenes ∥ assets_all 合并包 (MERGE_LEVELS=1)'
: `scenes ∥ assets_core 首屏(引擎启动必需);关卡库分片 + 关卡 bundle 进关按需 (${levelPackStats ? levelPackStats.packed : '?'} 关)`;
: `scenes ∥ assets_core 首屏(引擎启动必需);关卡库分片 + 关卡 bundle 进关按需 (${levelPackStats ? levelPackStats.packed : '?'} / ${levelPackStats ? levelPackStats.packs : '?'})`;
console.log('\n完成。运行时包 → 本地 import-to-unity.sh / OSS unitycdndir同一目录');
printPackageReport(outDir, { preloadNote });

View File

@@ -32,10 +32,10 @@ usage() {
echo " --skip-manifest 不生成 deploy/ 清单" >&2
echo " --zip 额外生成 build/mstest5-runtime.zip" >&2
echo "" >&2
echo "默认分包: scenes 首屏assets_all / 关卡库分片 / 每关 bundle 进关按需" >&2
echo "默认分包: scenes 首屏assets_all / 关卡库分片 / 每 15 关一包进关按需" >&2
echo " MERGE_LEVELS=1 合并 level-prefabs 进 assets_all不推荐" >&2
echo "运行时包结构(本地 static/unity = OSS unitycdndir:" >&2
echo " Build/ StreamingAssets/ levels-db-index.json(.br) levels-database.json(.br)" >&2
echo " Build/ StreamingAssets/ levels-db-index.json(.br)" >&2
echo " 首屏: scenes_all + assets_all进关: 关卡库分片 + 关卡 bundle" >&2
echo "" >&2
echo "步骤 2: scratch-gui/static/unity/import-to-unity.sh" >&2

View File

@@ -65,6 +65,60 @@ function brotliCompressWebglBundles(webglDir, opts = {}) {
return { count, saved };
}
/**
* 写出 Cocos loader 只需的 catalog3 个 bundle 名)。
* Unity 原 catalog 含数千 prefab 路径,对 Cocos 无用且首屏白下 ~1 MB。
*/
function writeSlimCatalog(catalogPath, bundleNames) {
const prefix = '{UnityEngine.AddressableAssets.Addressables.RuntimePath}/WebGL/';
const ids = [];
for (const name of bundleNames) {
if (name) ids.push(prefix + name);
}
const slim = {
m_LocatorId: 'AddressablesMainContentCatalog',
m_InternalIds: ids,
};
fs.writeFileSync(catalogPath, JSON.stringify(slim), 'utf8');
return Buffer.byteLength(JSON.stringify(slim), 'utf8');
}
/**
* 已有 .br 则删掉未压缩原文。loader 优先请求 .br。
* 保留 catalog / settings / levels-db-index 明文(很小,便于校验)。
* KEEP_PLAIN=1 跳过。
*/
function stripPlainIfBrotli(root, opts = {}) {
if (process.env.KEEP_PLAIN === '1') {
return { removed: 0, saved: 0, skipped: true };
}
const keep = new Set(opts.keepNames || [
'catalog.json',
'settings.json',
'levels-db-index.json',
]);
let removed = 0;
let saved = 0;
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) {
walk(p);
continue;
}
if (ent.name.endsWith('.br') || keep.has(ent.name)) continue;
const br = `${p}.br`;
if (!fs.existsSync(br) || !fs.statSync(br).isFile()) continue;
saved += fs.statSync(p).size;
fs.unlinkSync(p);
removed += 1;
}
}
walk(root);
return { removed, saved, skipped: false };
}
/**
* 调整预加载 bundle默认只预加载 mainresources / level-prefabs 按需加载。
* @param {object} opts
@@ -142,6 +196,8 @@ function printPackageReport(outDir, opts = {}) {
['assets/internal', path.join(outDir, 'assets', 'internal')],
['levels-database.json', path.join(outDir, 'levels-database.json')],
['levels-database.json.br', path.join(outDir, 'levels-database.json.br')],
['levels-db-index.json', path.join(outDir, 'levels-db-index.json')],
['StreamingAssets', path.join(outDir, 'StreamingAssets')],
['Build', path.join(outDir, 'Build')],
];
let total = 0;
@@ -165,6 +221,8 @@ module.exports = {
minifyLevelsDatabase,
brotliCompressFile,
brotliCompressWebglBundles,
writeSlimCatalog,
stripPlainIfBrotli,
patchPreloadSettings,
patchSplashSettings,
patchSplashInZipBundle,

View File

@@ -4,11 +4,10 @@
* 运行时包(唯一真相):
* Build/
* StreamingAssets/
* levels-database.json
* levels-database.json.br
* levels-db-index.json
* levels-db-index.json.br
* StreamingAssets/aa/levels-db/
* StreamingAssets/aa/levels-db/ (默认仅 .json.br
* 可选: levels-database.json(.br) — KEEP_LEGACY_DB=1
*
* 不含 index.html / TemplateData仅 standalone-player 独立调试页使用)
*/
@@ -49,6 +48,19 @@ function listRuntimeFiles(packDir) {
return files.sort((a, b) => a.rel.localeCompare(b.rel));
}
function existsPlainOrBr(filePath) {
if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) return true;
const br = `${filePath}.br`;
return fs.existsSync(br) && fs.statSync(br).size > 0;
}
function readJsonPlainOrBr(filePath) {
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
throw new Error(`缺少 JSON: ${filePath}`);
}
function bundleNamesFromCatalog(catalogPath) {
if (!fs.existsSync(catalogPath)) return [];
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
@@ -67,32 +79,32 @@ function assertRuntimePack(packDir, opts) {
if (!fs.existsSync(loader)) {
throw new Error(`缺少运行时包: ${loader}`);
}
if (!fs.existsSync(catalog)) {
throw new Error(`缺少运行时包: ${catalog}`);
if (!existsPlainOrBr(catalog)) {
throw new Error(`缺少运行时包: ${catalog}(.br)`);
}
if (!fs.existsSync(path.join(packDir, 'levels-db-index.json'))) {
throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-db-index.json')}`);
if (!existsPlainOrBr(path.join(packDir, 'levels-db-index.json'))) {
throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-db-index.json')}(.br)`);
}
if (!fs.existsSync(path.join(packDir, 'levels-database.json'))) {
throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-database.json')}`);
}
const names = bundleNamesFromCatalog(catalog);
const names = fs.existsSync(catalog) ? bundleNamesFromCatalog(catalog) : [];
if (opts.requireLevelsBundle && !names.some((n) => n.includes('levels_all'))) {
throw new Error('catalog 缺少 levels_all 分包');
}
if (opts.requireLevelsManifest && !fs.existsSync(levelsManifest)) {
throw new Error(`缺少运行时包: ${levelsManifest}`);
if (opts.requireLevelsManifest && !existsPlainOrBr(levelsManifest)) {
throw new Error(`缺少运行时包: ${levelsManifest}(.br)`);
}
if (opts.requireLevelsManifest) {
const manifest = JSON.parse(fs.readFileSync(levelsManifest, 'utf8'));
if (opts.requireLevelsManifest && fs.existsSync(levelsManifest)) {
const manifest = readJsonPlainOrBr(levelsManifest);
const count = Object.keys(manifest.levels || {}).length;
if (count < 1) throw new Error('levels-manifest.json 无关卡条目');
}
for (const name of names) {
const p = path.join(packDir, 'StreamingAssets/aa/WebGL', name);
if (!fs.existsSync(p) || fs.statSync(p).size < 100) {
throw new Error(`bundle 无效: ${p}`);
if (!existsPlainOrBr(p)) {
throw new Error(`bundle 无效: ${p}(.br)`);
}
const br = `${p}.br`;
const sz = fs.existsSync(p) ? fs.statSync(p).size : fs.statSync(br).size;
if (sz < 50) throw new Error(`bundle 过小: ${p}`);
}
}
@@ -101,6 +113,7 @@ module.exports = {
RUNTIME_DIRS,
walkFiles,
listRuntimeFiles,
existsPlainOrBr,
bundleNamesFromCatalog,
assertRuntimePack,
};

View File

@@ -2,9 +2,10 @@
/**
* 将 build/assets/level-prefabs 拆为:
* - shell: config.json + index.js进 assets_all 首屏)
* - 每关一包: assets/level-prefabs/import/.../*.json
* - 每 15 关一包: assets/level-prefabs/import/.../*.json
*
* 输出 levels-manifest.json 供 loader 按 levelId 按需下载。
* 输出 levels-manifest.json 供 loader 按 levelId 按需下载(同包关卡共享 bundle
* LEVELS_PER_PACK 可覆盖每包关卡数,默认 15。
*/
const crypto = require('crypto');
const fs = require('fs');
@@ -12,6 +13,8 @@ const path = require('path');
const { execSync } = require('child_process');
const { formatBytes } = require('./package-optimize');
const DEFAULT_LEVELS_PER_PACK = 15;
function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
function hashFileMd5(filePath) {
@@ -35,6 +38,11 @@ function walkJsonFiles(dir, out = []) {
return out;
}
function levelsPerPack() {
const n = parseInt(process.env.LEVELS_PER_PACK || String(DEFAULT_LEVELS_PER_PACK), 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_LEVELS_PER_PACK;
}
/** 扫描 import/*.json从预制体名 Level{id} 建立 levelId → 相对路径 */
function indexImportFiles(levelPrefabsDir) {
const importRoot = path.join(levelPrefabsDir, 'import');
@@ -80,52 +88,68 @@ function splitLevelBundles(levelPrefabsDir, webglDir, manifestOutPath, packTmpDi
throw new Error(`缺少 level-prefabs/config.json: ${levelPrefabsDir}`);
}
const packSize = levelsPerPack();
const configLevels = readConfigLevels(levelPrefabsDir);
const importByLevel = indexImportFiles(levelPrefabsDir);
mkdirp(webglDir);
const stageBase = packTmpDir || path.join(webglDir, '..', '.level-pack-tmp');
mkdirp(stageBase);
const manifest = {
version: 1,
shell: ['assets/level-prefabs/config.json', 'assets/level-prefabs/index.js'],
levels: {},
};
let packed = 0;
let totalBytes = 0;
const ready = [];
const missing = [];
for (const [levelId, meta] of configLevels) {
const importRel = importByLevel.get(levelId);
if (!importRel) {
missing.push(levelId);
continue;
}
const stageRoot = path.join(stageBase, levelId);
const assetRoot = path.join(stageRoot, 'assets', 'level-prefabs');
mkdirp(path.dirname(path.join(assetRoot, importRel)));
fs.copyFileSync(
path.join(levelPrefabsDir, importRel),
path.join(assetRoot, importRel),
);
ready.push({ levelId, meta, importRel });
}
ready.sort((a, b) => Number(a.levelId) - Number(b.levelId));
const zipTmp = path.join(stageBase, `${levelId}.zip`);
const manifest = {
version: 2,
packSize,
shell: ['assets/level-prefabs/config.json', 'assets/level-prefabs/index.js'],
levels: {},
};
let packed = 0;
let packs = 0;
let totalBytes = 0;
for (let i = 0; i < ready.length; i += packSize) {
const group = ready.slice(i, i + packSize);
const firstId = group[0].levelId;
const lastId = group[group.length - 1].levelId;
const stageRoot = path.join(stageBase, `pack-${firstId}-${lastId}`);
const assetRoot = path.join(stageRoot, 'assets', 'level-prefabs');
for (const item of group) {
mkdirp(path.dirname(path.join(assetRoot, item.importRel)));
fs.copyFileSync(
path.join(levelPrefabsDir, item.importRel),
path.join(assetRoot, item.importRel),
);
}
const zipTmp = path.join(stageBase, `pack-${firstId}-${lastId}.zip`);
zipDir(stageRoot, zipTmp);
const hash = hashFileMd5(zipTmp);
const bundleName = `defaultlocalgroup_level_${levelId}_${hash}.bundle`;
const bundleName = `defaultlocalgroup_levels_${firstId}_${lastId}_${hash}.bundle`;
fs.copyFileSync(zipTmp, path.join(webglDir, bundleName));
const size = fs.statSync(zipTmp).size;
totalBytes += size;
packs += 1;
for (const item of group) {
manifest.levels[item.levelId] = {
bundle: bundleName,
uuid: item.meta.uuid,
};
packed += 1;
}
manifest.levels[levelId] = {
bundle: bundleName,
path: meta.path,
uuid: meta.uuid,
files: [`assets/level-prefabs/${importRel}`],
bytes: size,
};
packed += 1;
fs.rmSync(stageRoot, { recursive: true, force: true });
fs.unlinkSync(zipTmp);
}
@@ -134,14 +158,15 @@ function splitLevelBundles(levelPrefabsDir, webglDir, manifestOutPath, packTmpDi
fs.writeFileSync(manifestOutPath, JSON.stringify(manifest), 'utf8');
console.log(`>>> 关卡分包: ${packed} 关, 合计 ${formatBytes(totalBytes)}, 均 ${formatBytes(Math.round(totalBytes / Math.max(packed, 1)))}/`);
console.log(`>>> 关卡分包: ${packed} / ${packs} 包 (${packSize} 关/包), 合计 ${formatBytes(totalBytes)}, 均 ${formatBytes(Math.round(totalBytes / Math.max(packs, 1)))}/`);
if (missing.length) {
console.warn(`>>> 警告: ${missing.length} 关在 config 中无 import 文件 (例: ${missing.slice(0, 5).join(', ')})`);
}
return { manifest, packed, missing, totalBytes };
return { manifest, packed, packs, packSize, missing, totalBytes };
}
module.exports = {
DEFAULT_LEVELS_PER_PACK,
splitLevelBundles,
indexImportFiles,
readConfigLevels,

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# 将运行时包同步到 scratch-gui/static/unity/
# 与 OSS unitycdndir 内容完全一致Build/ + StreamingAssets/ + levels-database.*
# 与 OSS unitycdndir 内容完全一致Build/ + StreamingAssets/ + levels-db-index.*
#
# bash tools/sync-unity-package-to-static.sh <packDir> <staticUnityDir>
set -euo pipefail
@@ -13,8 +13,8 @@ if [[ ! -f "$PACK_DIR/Build/mstest5.loader.js" ]]; then
echo "错误: 缺少 $PACK_DIR/Build/mstest5.loader.js" >&2
exit 1
fi
if [[ ! -f "$PACK_DIR/StreamingAssets/aa/catalog.json" ]]; then
echo "错误: 缺少 $PACK_DIR/StreamingAssets/aa/catalog.json" >&2
if [[ ! -f "$PACK_DIR/StreamingAssets/aa/catalog.json" && ! -f "$PACK_DIR/StreamingAssets/aa/catalog.json.br" ]]; then
echo "错误: 缺少 $PACK_DIR/StreamingAssets/aa/catalog.json(.br)" >&2
exit 1
fi
@@ -30,7 +30,7 @@ echo " 目标: $UNITY_STATIC"
rsync "${RSYNC_FLAGS[@]}" "$PACK_DIR/Build/" "$UNITY_STATIC/Build/"
rsync "${RSYNC_FLAGS[@]}" "$PACK_DIR/StreamingAssets/" "$UNITY_STATIC/StreamingAssets/"
for db in levels-database.json levels-database.json.br; do
for db in levels-db-index.json levels-db-index.json.br levels-database.json levels-database.json.br; do
if [[ -f "$PACK_DIR/$db" ]]; then
if [[ "$DRY_RUN" -eq 1 ]]; then
echo " [dry-run] cp $PACK_DIR/$db$UNITY_STATIC/"
@@ -40,6 +40,15 @@ for db in levels-database.json levels-database.json.br; do
fi
fi
done
# 全量库已默认不打进包;清掉 static 里旧的大文件
if [[ "$DRY_RUN" -eq 0 ]]; then
for stale in levels-database.json levels-database.json.br; do
if [[ ! -f "$PACK_DIR/$stale" && -f "$UNITY_STATIC/$stale" ]]; then
rm -f "$UNITY_STATIC/$stale"
echo " removed stale $stale"
fi
done
fi
if [[ "$DRY_RUN" -eq 0 ]]; then
for legacy in index.js application.js cocos-bridge.js assets cocos-js src index.html TemplateData; do

View File

@@ -65,8 +65,8 @@ const lines = [
'## 上传后验证',
`curl -I "${base}/Build/mstest5.loader.js"`,
`curl -I "${base}/StreamingAssets/aa/catalog.json"`,
`curl -I "${base}/levels-database.json"`,
...bundleNames.map((n) => `curl -I "${base}/StreamingAssets/aa/WebGL/${n}"`),
`curl -I "${base}/levels-db-index.json.br"`,
...bundleNames.map((n) => `curl -I "${base}/StreamingAssets/aa/WebGL/${n}.br"`),
];
fs.writeFileSync(manifestPath, lines.join('\n'), 'utf8');
@@ -80,7 +80,7 @@ const readme = [
` ${packDir}/`,
' ├── Build/',
' ├── StreamingAssets/',
' └── levels-database.json(.br)',
' └── levels-db-index.json(.br)',
'',
'本地: import-to-unity.sh → scratch-gui/static/unity/(同上结构)',
'CDN: 上传运行时包全部内容 → config.js unitycdndir + usecdn:true',
@@ -98,8 +98,14 @@ if (makeZip) {
zipPath = path.join(path.dirname(packDir), 'mstest5-runtime.zip');
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
const items = ['Build', 'StreamingAssets'];
if (fs.existsSync(path.join(packDir, 'levels-database.json'))) items.push('levels-database.json');
if (fs.existsSync(path.join(packDir, 'levels-database.json.br'))) items.push('levels-database.json.br');
for (const name of [
'levels-db-index.json',
'levels-db-index.json.br',
'levels-database.json',
'levels-database.json.br',
]) {
if (fs.existsSync(path.join(packDir, name))) items.push(name);
}
execSync(`cd "${packDir}" && zip -0 -q -r "${zipPath}" ${items.join(' ')}`, { stdio: 'pipe' });
}