Complete Cocos Creator port with level bundles, themes, and tooling.

Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""
将 levels-database.json 烘焙为 Cocos 关卡预制体(.prefab + .prefab.meta
预制体结构(对齐 Unity LevelN.prefab:
Level{N}
├── Border ← 墙块 Sprite 子节点(纯视觉)
├── Ground ← 地面 Sprite 子节点
└── (挂 LevelMapDataground/border 逻辑 JSON)
用法:
python3 tools/bake_cocos_level_prefabs.py \\
--db assets/level-data/levels-database.json \\
--out-dir assets/bundle-level-prefabs/level-prefabs \\
--limit 0
"""
from __future__ import annotations
import argparse
import json
import random
import re
import string
import uuid
from pathlib import Path
from level_id import LEVEL_ID_BASE, touch_database
LAYER_DEFAULT = 1073741824
LAYER_UI_2D = 33554432 # 编辑器 2D 视图可见(对齐 SpriteSplash.prefab
CELL = 100
def resolve_level_map_data_type() -> str:
"""从 Creator 编译产物读取 LevelMapData 的 __type___RF.push 第二参数)。"""
chunk = Path("temp/programming/packer-driver/targets/preview/chunks/bb/bbf6a3729c922dc4638933a67da37eeb23e90aee.js")
if chunk.is_file():
text = chunk.read_text(encoding="utf-8", errors="ignore")
m = re.search(r'_RF\.push\(\{\},\s*"([^"]+)",\s*"LevelMapData"', text)
if m:
return m.group(1)
return "d4e5fanuMlNDh8qO0xdbn+K"
LEVEL_MAP_DATA_TYPE = resolve_level_map_data_type()
GRID_SNAP_HELPER_TYPE = "739b2brZKNN16CEy+hU2Yo2"
DEFAULT_SPRITE_UUID = "5625da25-9915-416f-be60-c6decb355672@f9941"
TILE_W = 101
TILE_H = 80
# Unity Texture/*.png.meta spritePivot
TILE_PIVOTS: dict[str, tuple[float, float]] = {
"Baseblock": (0.5, 0.92),
"JumpBlock": (0.5, 0.77),
"WallBlock": (0.5, 0.67),
"kuai11": (0.5, 1.01),
}
def tile_pivot(tile_name: str) -> tuple[float, float]:
return TILE_PIVOTS.get(tile_name, (0.5, 0.92))
def tile_draw_size(width: int, height: int) -> tuple[float, float]:
"""宽度贴满格子 100px与 TileSizes.getTileDrawSize 一致)。"""
scale = CELL / width
return width * scale, height * scale
def theme_from_cfg(cfg: dict, override: str) -> str:
if override:
return override
t = cfg.get("theme")
if isinstance(t, str) and t:
return t
return "silu"
def ground_sprite_path(theme: str, tile_name: str) -> str:
key = tile_name if tile_name in ("Baseblock", "JumpBlock") else "Baseblock"
return f"textures/{theme}/{key}"
def border_sprite_path(theme: str, tile_val) -> str:
if tile_val is True or tile_val is None:
name = "WallBlock"
elif isinstance(tile_val, str):
name = tile_val
else:
name = "WallBlock"
return f"textures/{theme}/{name}"
def read_sprite_size(project: Path, texture_path: str) -> tuple[int, int]:
meta_path = project / "assets/resources" / f"{texture_path}.png.meta"
if meta_path.is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
ud = meta.get("subMetas", {}).get("f9941", {}).get("userData", {})
w, h = ud.get("width"), ud.get("height")
if w and h:
return int(w), int(h)
except Exception:
pass
return TILE_W, TILE_H
def rebuild_index_from_disk(out: Path) -> dict[str, str]:
index: dict[str, str] = {}
for p in sorted(out.glob("Level*.prefab")):
lid = p.stem.replace("Level", "")
if lid.isdigit():
index[lid] = f"level-prefabs/Level{lid}"
return index
def write_prefab_index(project: Path, out: Path, index: dict[str, str]) -> Path:
"""写入 tools/(非 assets避免 Cocos 编辑器锁定 _index.json 报 Can not change asset。"""
index_path = project / "tools" / "level-prefab-index.json"
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(json.dumps(index, indent=2), encoding="utf-8")
return index_path
def write_prefab_uuid_index(project: Path, out: Path) -> Path:
"""写入 assets/level-data/level-prefab-uuids.json供编辑器预览 assetManager.loadAny(uuid)。"""
uuids: dict[str, str] = {}
for meta in sorted(out.glob("Level*.prefab.meta")):
m = re.match(r"Level(\d+)\.prefab\.meta$", meta.name)
if not m:
continue
try:
data = json.loads(meta.read_text(encoding="utf-8"))
uid = data.get("uuid")
if uid:
uuids[m.group(1)] = uid
except Exception:
continue
uuid_path = project / "assets" / "level-data" / "level-prefab-uuids.json"
uuid_path.parent.mkdir(parents=True, exist_ok=True)
uuid_path.write_text(json.dumps(uuids, indent=2), encoding="utf-8")
return uuid_path
def read_sprite_uuid(project: Path, texture_path: str) -> str:
meta_path = project / "assets/resources" / f"{texture_path}.png.meta"
if meta_path.is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
base = meta.get("uuid")
if base:
return f"{base}@f9941"
except Exception:
pass
return DEFAULT_SPRITE_UUID
def uid() -> str:
return str(uuid.uuid4())
def vec3(x: float, y: float, z: float = 0) -> dict:
return {"__type__": "cc.Vec3", "x": x, "y": y, "z": z}
def quat() -> dict:
return {"__type__": "cc.Quat", "x": 0, "y": 0, "z": 0, "w": 1}
def cell_pos(x: int, y: int) -> tuple[float, float]:
"""Unity Tilemap tileAnchor (0.5,0.5)格子中心CellSize (1,0.5,1)。"""
half_w = CELL * 0.5
half_h = CELL * 0.25
return (x - y) * half_w, (x + y) * half_h + half_h
def file_id() -> str:
chars = string.ascii_letters + string.digits
return "".join(random.choices(chars, k=22))
def finalize_prefab_infos(objs: list[dict], root_id: int) -> None:
"""每个节点必须挂 cc.PrefabInfo否则编辑器 open prefab 会报 instance null。"""
for obj in objs:
if obj.get("__type__") != "cc.Node":
continue
pid = len(objs)
objs.append(
{
"__type__": "cc.PrefabInfo",
"root": {"__id__": root_id},
"asset": {"__id__": 0},
"fileId": file_id(),
}
)
obj["_prefab"] = {"__id__": pid}
class Builder:
def __init__(self) -> None:
self.objs: list[dict] = []
def add(self, obj: dict) -> int:
self.objs.append(obj)
return len(self.objs) - 1
def comp_prefab_info(self) -> int:
return self.add({"__type__": "cc.CompPrefabInfo", "fileId": file_id()})
def node(
self,
name: str,
parent: int | None,
children: list[int],
components: list[int],
x: float,
y: float,
layer: int = LAYER_UI_2D,
) -> int:
return self.add(
{
"__type__": "cc.Node",
"_name": name,
"_objFlags": 0,
"_parent": {"__id__": parent} if parent is not None else None,
"_children": [{"__id__": c} for c in children],
"_active": True,
"_components": [{"__id__": c} for c in components],
"_prefab": None,
"_lpos": vec3(x, y, 0),
"_lrot": quat(),
"_lscale": vec3(1, 1, 1),
"_layer": layer,
"_euler": vec3(0, 0, 0),
"_id": "",
}
)
def ui_transform(self, node_id: int, anchor: tuple[float, float], size: tuple[int, int]) -> int:
cpi = self.comp_prefab_info()
ax, ay = anchor
tw, th = size
ui_id = self.add(
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"node": {"__id__": node_id},
"_enabled": True,
"__prefab": {"__id__": cpi},
"_priority": 0,
"_contentSize": {"__type__": "cc.Size", "width": tw, "height": th},
"_anchorPoint": {"__type__": "cc.Vec2", "x": ax, "y": ay},
"_id": "",
}
)
return ui_id
def sprite_comp(self, node_id: int, sprite_uuid: str, alpha: int = 255) -> int:
cpi = self.comp_prefab_info()
return self.add(
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"node": {"__id__": node_id},
"_enabled": True,
"__prefab": {"__id__": cpi},
"_visFlags": 0,
"_customMaterial": None,
"_srcBlendFactor": 2,
"_dstBlendFactor": 4,
"_color": {
"__type__": "cc.Color",
"r": 255,
"g": 255,
"b": 255,
"a": alpha,
},
"_spriteFrame": {"__uuid__": sprite_uuid},
"_type": 0,
"_fillType": 0,
"_sizeMode": 0,
"_fillCenter": {"__type__": "cc.Vec2", "x": 0, "y": 0},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": True,
"_useGrayscale": False,
"_atlas": None,
"_id": "",
}
)
def grid_snap_helper(self, node_id: int) -> int:
cpi = self.comp_prefab_info()
return self.add(
{
"__type__": GRID_SNAP_HELPER_TYPE,
"_name": "",
"_objFlags": 0,
"node": {"__id__": node_id},
"_enabled": True,
"__prefab": {"__id__": cpi},
"snapEnabled": True,
"showGrid": True,
"gridRadius": 12,
"gridPadding": 2,
"highlightOccupied": True,
"syncNodeNames": True,
"_id": "",
}
)
def level_map_data(self, node_id: int, level_id: int, ground: dict, border: dict, theme: str) -> int:
cpi = self.comp_prefab_info()
return self.add(
{
"__type__": LEVEL_MAP_DATA_TYPE,
"_name": "",
"_objFlags": 0,
"node": {"__id__": node_id},
"_enabled": True,
"__prefab": {"__id__": cpi},
"levelID": level_id,
"groundJson": json.dumps(ground, ensure_ascii=False, separators=(",", ":")),
"borderJson": json.dumps(border, ensure_ascii=False, separators=(",", ":")),
"theme": theme,
"_id": "",
}
)
def tile(
self,
name: str,
parent: int,
x: int,
y: int,
sprite_uuid: str,
tile_name: str,
size: tuple[int, int],
) -> int:
px, py = cell_pos(x, y)
nid = self.node(name, parent, [], [], px, py, LAYER_UI_2D)
draw_w, draw_h = tile_draw_size(size[0], size[1])
ui_id = self.ui_transform(nid, tile_pivot(tile_name), (draw_w, draw_h))
sp_id = self.sprite_comp(nid, sprite_uuid)
self.objs[nid]["_components"] = [{"__id__": ui_id}, {"__id__": sp_id}]
return nid
def build_prefab(level_id: int, cfg: dict, project: Path, theme: str = "") -> list:
ground = cfg.get("ground") or {}
border = cfg.get("border") or {}
theme = theme_from_cfg(cfg, theme)
b = Builder()
root_id = 1
b.add(
{
"__type__": "cc.Prefab",
"_name": f"Level{level_id}",
"_objFlags": 0,
"_native": "",
"data": {"__id__": root_id},
"optimizationPolicy": 0,
"persistent": False,
}
)
b.add(
{
"__type__": "cc.Node",
"_name": f"Level{level_id}",
"_objFlags": 0,
"_parent": None,
"_children": [],
"_active": True,
"_components": [],
"_prefab": None,
"_lpos": vec3(0, 0, 0),
"_lrot": quat(),
"_lscale": vec3(1, 1, 1),
"_layer": LAYER_UI_2D,
"_euler": vec3(0, 0, 0),
"_id": "",
}
)
border_tiles: list[int] = []
for key in sorted(border.keys()):
xs, ys = key.split(",")
x, y = int(xs), int(ys)
val = border[key]
if val is True or val is None:
bname = "WallBlock"
elif isinstance(val, str):
bname = val
else:
bname = "WallBlock"
spr_wall = read_sprite_uuid(project, border_sprite_path(theme, border[key]))
wall_path = border_sprite_path(theme, border[key])
border_tiles.append(
b.tile(f"b_{x}_{y}", -1, x, y, spr_wall, bname, read_sprite_size(project, wall_path))
)
ground_tiles: list[int] = []
for key in sorted(ground.keys()):
xs, ys = key.split(",")
x, y = int(xs), int(ys)
tile_name = ground[key]
gpath = ground_sprite_path(theme, tile_name)
spr = read_sprite_uuid(project, gpath)
ground_tiles.append(
b.tile(f"g_{x}_{y}", -1, x, y, spr, tile_name, read_sprite_size(project, gpath))
)
border_id = b.node("Border", root_id, border_tiles, [], 0, 0, LAYER_UI_2D)
ground_id = b.node("Ground", root_id, ground_tiles, [], 0, 0, LAYER_UI_2D)
map_data_id = b.level_map_data(root_id, level_id, ground, border, theme)
grid_snap_id = b.grid_snap_helper(root_id)
# Unity 顺序Ground 在下Border 在上
b.objs[root_id]["_children"] = [{"__id__": ground_id}, {"__id__": border_id}]
b.objs[root_id]["_components"] = [{"__id__": map_data_id}, {"__id__": grid_snap_id}]
for tid in border_tiles:
b.objs[tid]["_parent"] = {"__id__": border_id}
for tid in ground_tiles:
b.objs[tid]["_parent"] = {"__id__": ground_id}
finalize_prefab_infos(b.objs, root_id)
return b.objs
def write_meta(prefab_path: Path) -> None:
meta_path = prefab_path.with_suffix(".prefab.meta")
prefab_uuid = uid()
if meta_path.is_file():
try:
existing = json.loads(meta_path.read_text(encoding="utf-8"))
if existing.get("uuid"):
prefab_uuid = existing["uuid"]
except Exception:
pass
meta = {
"ver": "1.1.50",
"importer": "prefab",
"imported": True,
"uuid": prefab_uuid,
"files": [".json"],
"subMetas": {},
"userData": {"syncNodeName": prefab_path.stem},
}
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--db", required=True)
ap.add_argument("--out-dir", required=True)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--level-id", type=int, default=0, help="只烘焙指定关卡0=全部")
ap.add_argument("--max-level-id", type=int, default=0, help="只烘焙 levelID <= 此值的关卡")
ap.add_argument("--rebuild-index", action="store_true", help="仅重建 tools/level-prefab-index.json不烘焙 prefab")
ap.add_argument("--theme", default="", help="贴图主题 silu/sanxing/snow/…,空则从关卡 theme 字段读取")
args = ap.parse_args()
project = Path(__file__).resolve().parent.parent
db = json.loads(Path(args.db).read_text(encoding="utf-8"))
out = Path(args.out_dir)
out.mkdir(parents=True, exist_ok=True)
levels = db["levels"]
ids = sorted(int(k) for k in levels.keys())
if args.rebuild_index and args.level_id == 0 and args.max_level_id == 0 and args.limit == 0:
index = rebuild_index_from_disk(out)
index_path = write_prefab_index(project, out, index)
uuid_path = write_prefab_uuid_index(project, out)
print(f"Index rebuilt -> {index_path} ({len(index)} entries), uuids -> {uuid_path}")
return
if args.level_id > 0:
ids = [args.level_id] if str(args.level_id) in levels else []
elif args.max_level_id > 0:
ids = [i for i in ids if i <= args.max_level_id]
elif args.limit > 0:
ids = ids[: args.limit]
for lid in ids:
cfg = levels[str(lid)]
p = out / f"Level{lid}.prefab"
p.write_text(json.dumps(build_prefab(lid, cfg, project, args.theme), indent=2), encoding="utf-8")
write_meta(p)
if lid % 100 == 0:
print(f" baked Level{lid}")
index = rebuild_index_from_disk(out)
index_path = write_prefab_index(project, out, index)
touch_database(Path(args.db), ids)
write_prefab_uuid_index(project, out)
print(f"Baked {len(ids)} prefabs -> {out} (index: {len(index)} entries -> {index_path})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
烘焙「地图模块」预制体(对齐 Unity Tile Palette 中的单个 Tile
输出: assets/resources/map-tiles/MapTile_*.prefab
在关卡编辑器右侧面板中作为笔刷模块使用。
"""
from __future__ import annotations
import json
import random
import string
import uuid
from pathlib import Path
LAYER_DEFAULT = 1073741824
CELL = 56
TILE_SIZE = CELL * 0.9
# 与 Unity SILU 调色板一致
MAP_TILES = [
{
"name": "MapTile_Baseblock",
"display": "地面 Baseblock",
"layer": "ground",
"tileKey": "Baseblock",
"texture": "textures/silu/Baseblock",
"spriteUuid": "5625da25-9915-416f-be60-c6decb355672@f9941",
"alpha": 255,
},
{
"name": "MapTile_JumpBlock",
"display": "跳跃 JumpBlock",
"layer": "ground",
"tileKey": "JumpBlock",
"texture": "textures/silu/JumpBlock",
"spriteUuid": None,
"alpha": 255,
},
{
"name": "MapTile_WallBlock",
"display": "墙 WallBlock",
"layer": "border",
"tileKey": "WallBlock",
"texture": "textures/silu/WallBlock",
"spriteUuid": None,
"alpha": 255,
},
{
"name": "MapTile_Decor23",
"display": "装饰 素材23",
"layer": "border",
"tileKey": "Decor23",
"texture": "textures/silu/Decor23",
"spriteUuid": None,
"alpha": 255,
},
]
def uid() -> str:
return str(uuid.uuid4())
def vec3(x: float, y: float, z: float = 0) -> dict:
return {"__type__": "cc.Vec3", "x": x, "y": y, "z": z}
def quat() -> dict:
return {"__type__": "cc.Quat", "x": 0, "y": 0, "z": 0, "w": 1}
def file_id() -> str:
chars = string.ascii_letters + string.digits
return "".join(random.choices(chars, k=22))
def read_sprite_uuid_from_meta(project: Path, texture_path: str) -> str | None:
meta_path = project / "assets/resources" / f"{texture_path}.png.meta"
if not meta_path.is_file():
return None
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
base = meta.get("uuid")
if base:
return f"{base}@f9941"
except Exception:
pass
return None
def build_tile_prefab(tile: dict) -> list:
objs: list[dict] = []
root_id = 1
objs.append(
{
"__type__": "cc.Prefab",
"_name": tile["name"],
"_objFlags": 0,
"_native": "",
"data": {"__id__": root_id},
"optimizationPolicy": 0,
"persistent": False,
}
)
objs.append(
{
"__type__": "cc.Node",
"_name": tile["name"],
"_objFlags": 0,
"_parent": None,
"_children": [],
"_active": True,
"_components": [],
"_prefab": {"__id__": 2},
"_lpos": vec3(0, 0, 0),
"_lrot": quat(),
"_lscale": vec3(1, 1, 1),
"_layer": LAYER_DEFAULT,
"_euler": vec3(0, 0, 0),
"_id": "",
}
)
objs.append(
{
"__type__": "cc.PrefabInfo",
"root": {"__id__": root_id},
"asset": {"__id__": 0},
"fileId": file_id(),
}
)
return objs
def write_meta(prefab_path: Path, prefab_uuid: str) -> None:
meta = {
"ver": "1.1.50",
"importer": "prefab",
"imported": True,
"uuid": prefab_uuid,
"files": [".json"],
"subMetas": {},
"userData": {"syncNodeName": prefab_path.stem},
}
prefab_path.with_suffix(".prefab.meta").write_text(
json.dumps(meta, indent=2), encoding="utf-8"
)
def main():
project = Path(__file__).resolve().parent.parent
out = project / "assets/resources/map-tiles"
out.mkdir(parents=True, exist_ok=True)
catalog = []
for tile in MAP_TILES:
sprite_uuid = tile.get("spriteUuid") or read_sprite_uuid_from_meta(
project, tile["texture"]
)
if not sprite_uuid:
sprite_uuid = "5625da25-9915-416f-be60-c6decb355672@f9941"
print(f" warn: {tile['name']} 使用 Baseblock 占位,请在编辑器导入 {tile['texture']}.png 后重烘焙")
p = out / f"{tile['name']}.prefab"
p.write_text(json.dumps(build_tile_prefab(tile), indent=2), encoding="utf-8")
write_meta(p, uid())
catalog.append(
{
"name": tile["name"],
"display": tile["display"],
"layer": tile["layer"],
"tileKey": tile["tileKey"],
"texture": tile["texture"],
"prefab": f"map-tiles/{tile['name']}",
}
)
print(f" baked {tile['name']}")
(out / "_palette.json").write_text(
json.dumps({"tiles": catalog}, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"Palette catalog -> {out / '_palette.json'}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
从 Unity Assets/Tile/{theme}.prefab + Assets/Texture/{theme} 生成 Cocos 调色板配置。
对齐 Unity 平铺调色板(如 sanxingWallBlock / JumpBlock / Baseblock / kuai11
"""
from __future__ import annotations
import json
import re
from pathlib import Path
# Unity 目录名 → Cocos / GameController 主题名
THEMES = {
"SILU": "silu",
"snow": "snow",
"sanxing": "sanxing",
"Chinese": "chinese",
"numMan": "numMan",
"redArmy": "redarmy",
"RED": "redArmy",
"Level": "default",
}
GROUND_NAMES = {"Baseblock", "JumpBlock"}
BORDER_NAMES = {"WallBlock", "kuai11", "Decor23", "素材切图-23", "素材切图2-23", "小游戏素材红色_03"}
def parse_palette_guids(prefab_text: str) -> list[str]:
guids: list[str] = []
in_array = False
for line in prefab_text.splitlines():
if "m_TileAssetArray:" in line:
in_array = True
continue
if in_array and "m_TileSpriteArray:" in line:
break
if in_array and "guid:" in line:
m = re.search(r"guid: ([a-f0-9]+)", line)
if m:
guids.append(m.group(1))
return guids
def read_meta_guid(meta_path: Path) -> str | None:
try:
for line in meta_path.read_text(encoding="utf-8").splitlines():
if line.startswith("guid:"):
return line.split(":", 1)[1].strip()
except Exception:
pass
return None
def guid_to_tile_name(theme_dir: Path, guid: str) -> str | None:
if not theme_dir.is_dir():
return None
for meta in theme_dir.glob("*.asset.meta"):
if read_meta_guid(meta) == guid:
return meta.name.replace(".asset.meta", "")
return None
def build_theme(unity_root: Path, unity_folder: str, cocos_key: str) -> dict | None:
tile_prefab = unity_root / "Assets" / "Tile" / f"{unity_folder}.prefab"
tex_dir = unity_root / "Assets" / "Texture" / unity_folder
if unity_folder == "Level":
tex_dir = unity_root / "Assets" / "Texture"
if not tile_prefab.is_file():
return None
guids = parse_palette_guids(tile_prefab.read_text(encoding="utf-8"))
tiles = []
for i, g in enumerate(guids):
name = guid_to_tile_name(tex_dir, g)
if not name:
continue
layer = "ground" if name in GROUND_NAMES else "border"
tex_rel = f"textures/{cocos_key}/{name}"
if cocos_key == "default":
tex_rel = f"textures/default/{name}"
tiles.append(
{
"display": name,
"layer": layer,
"tileKey": name,
"texture": tex_rel,
"unityIndex": i,
}
)
return {"displayName": cocos_key, "tiles": tiles}
def main():
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--unity-root", required=True)
ap.add_argument("--out", default="assets/resources/map-tiles/palettes")
args = ap.parse_args()
project = Path(__file__).resolve().parent.parent
unity_root = Path(args.unity_root)
out_dir = project / args.out
out_dir.mkdir(parents=True, exist_ok=True)
all_themes: dict[str, dict] = {}
for unity_folder, cocos_key in THEMES.items():
t = build_theme(unity_root, unity_folder, cocos_key)
if not t or not t["tiles"]:
print(f" skip {unity_folder}")
continue
all_themes[cocos_key] = t
(out_dir / f"{cocos_key}.json").write_text(
json.dumps(t, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f" {cocos_key}: {len(t['tiles'])} tiles")
(out_dir / "_index.json").write_text(
json.dumps({"themes": all_themes}, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"Wrote -> {out_dir}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""从 Cocos 贴图 + Unity pivot 生成 tile-display-meta.json裁剪后可见尺寸"""
from __future__ import annotations
import json
import re
import struct
from pathlib import Path
UNITY_THEMES = {
"silu": "silu",
"snow": "snow",
"sanxing": "sanxing",
"chinese": "Chinese",
"numMan": "numMan",
"redarmy": "redArmy",
"default": "Level",
}
TILE_NAMES = [
"Baseblock",
"JumpBlock",
"WallBlock",
"kuai11",
"Decor23",
"素材切图-23",
"素材切图2-23",
"小游戏素材红色_03",
]
def png_size(path: Path) -> tuple[int, int]:
with path.open("rb") as f:
f.read(16)
return struct.unpack(">II", f.read(8))
def alpha_bbox(png_path: Path) -> tuple[int, int, int, int] | None:
try:
from PIL import Image
except ImportError:
return None
try:
img = Image.open(png_path).convert("RGBA")
return img.getbbox()
except Exception:
return None
def read_unity_sprite_meta(meta_path: Path) -> dict | None:
if not meta_path.is_file():
return None
text = meta_path.read_text(encoding="utf-8")
pivot = re.search(r"spritePivot:\s*\{x:\s*([^,]+),\s*y:\s*([^}]+)\}", text)
ppu = re.search(r"spritePixelsToUnits:\s*(\d+(?:\.\d+)?)", text)
if not pivot:
return None
return {
"pivotX": float(pivot.group(1)),
"pivotY": float(pivot.group(2)),
"ppu": float(ppu.group(1)) if ppu else 100.0,
}
def resolve_draw_rect(
png_path: Path,
full_w: int,
full_h: int,
pivot_x: float,
pivot_y: float,
tile_name: str,
) -> dict:
bbox = alpha_bbox(png_path)
if not bbox:
return {
"width": full_w,
"height": full_h,
"pivotX": pivot_x,
"pivotY": pivot_y,
"fitMul": 1.0,
}
left, top, right, bottom = bbox
tw, th = right - left, bottom - top
if tw <= 0 or th <= 0 or (tw >= full_w and th >= full_h):
return {
"width": full_w,
"height": full_h,
"pivotX": pivot_x,
"pivotY": pivot_y,
"fitMul": 1.0,
}
pivot_bottom = pivot_y * full_h
trim_bottom = full_h - bottom
pivot_bottom_trim = pivot_bottom - trim_bottom
meta_pivot_y = max(0.0, min(1.0, pivot_bottom_trim / th))
meta_pivot_x = max(0.0, min(1.0, (pivot_x * full_w - left) / tw))
fit_mul = 1.0
out_pivot_y = meta_pivot_y
# kuai11 篝火:底座贴齐地面格,略放大消除缝隙
if tile_name == "kuai11":
fit_mul = 1.12
# 底座在裁剪图底部pivot 放低使底座对齐格子
out_pivot_y = max(0.28, min(0.42, meta_pivot_y * 0.45))
return {
"width": tw,
"height": th,
"pivotX": round(meta_pivot_x, 4),
"pivotY": round(out_pivot_y, 4),
"fitMul": fit_mul,
}
def build_theme(cocos_tex: Path, unity_tex: Path, unity_folder: str, cocos_key: str) -> dict:
theme_dir = unity_tex if unity_folder == "Level" else unity_tex / unity_folder
cocos_dir = cocos_tex if cocos_key == "default" else cocos_tex / cocos_key
tiles: dict[str, dict] = {}
for name in TILE_NAMES:
png = cocos_dir / f"{name}.png"
if not png.is_file():
if name == "Decor23":
png = cocos_dir / "素材切图-23.png"
elif name == "素材切图-23":
png = cocos_dir / "Decor23.png"
if not png.is_file():
continue
unity_png = theme_dir / png.name
meta = read_unity_sprite_meta(unity_png.with_suffix(".png.meta"))
if not meta:
continue
w, h = png_size(png)
draw = resolve_draw_rect(png, w, h, meta["pivotX"], meta["pivotY"], name)
tiles[name] = {**draw, "ppu": meta["ppu"]}
return tiles
def main():
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--unity-root", required=True)
ap.add_argument("--out", default="assets/resources/theme/tile-display-meta.json")
args = ap.parse_args()
project = Path(__file__).resolve().parent.parent
unity_tex = Path(args.unity_root) / "Assets" / "Texture"
cocos_tex = project / "assets/resources/textures"
themes: dict[str, dict] = {}
for cocos_key, unity_folder in UNITY_THEMES.items():
tiles = build_theme(cocos_tex, unity_tex, unity_folder, cocos_key)
if tiles:
themes[cocos_key] = tiles
print(f" {cocos_key}: {len(tiles)} tiles")
out = project / args.out
out.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": 1, "themes": themes}
out.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Wrote -> {out}")
if __name__ == "__main__":
main()

62
tools/deploy-cdn.sh Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# CDN 部署:打包单一运行时包 + 生成上传清单
#
# bash tools/deploy-cdn.sh
# bash tools/deploy-cdn.sh --with-static
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
usage() {
cat <<'EOF'
CDN 部署usecdn:true
bash tools/deploy-cdn.sh # 打包 + 清单
bash tools/deploy-cdn.sh --with-static # 同时同步 static/unity
产物:
build/mstest5/ 运行时包 ← 上传 OSS与本地 static/unity 相同)
build/deploy/ 上传清单(不进 OSS
build/standalone-player/ 独立调试页(不进 OSS
EOF
}
CDN_BASE=""
SKIP_VERIFY=0
SKIP_PACK=0
DRY_RUN=0
UPLOAD_ONLY=0
WITH_STATIC=0
while [[ $# -gt 0 ]]; do
case "$1" in
--build) BUILD_DIR="$2"; shift 2 ;;
--code-html) CODE_HTML="$2"; shift 2 ;;
--cdn-base) CDN_BASE="$2"; shift 2 ;;
--upload-only) UPLOAD_ONLY=1; shift ;;
--with-static) WITH_STATIC=1; shift ;;
--skip-verify) SKIP_VERIFY=1; shift ;;
--skip-pack) SKIP_PACK=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage; exit 1 ;;
esac
done
BUILD_DIR="${BUILD_DIR:-$PROJECT_DIR/build/web-desktop}"
CODE_HTML="${CODE_HTML:-$HOME/tfrh/001code/001code-html}"
ARGS=(--build "$BUILD_DIR" --code-html "$CODE_HTML")
[[ -n "$CDN_BASE" ]] && ARGS+=(--cdn-base "$CDN_BASE")
[[ "$SKIP_PACK" -eq 1 ]] && ARGS+=(--skip-pack)
[[ "$DRY_RUN" -eq 1 ]] && ARGS+=(--dry-run)
[[ "$UPLOAD_ONLY" -eq 1 ]] && ARGS+=(--cdn-upload-only)
[[ "$WITH_STATIC" -eq 0 ]] && ARGS+=(--cdn-pure)
[[ -n "${UNITY_REF:-}" ]] && ARGS+=(--unity-ref "$UNITY_REF")
bash "$SCRIPT_DIR/deploy-to-001code.sh" "${ARGS[@]}"
echo ""
echo "==> 上传 build/mstest5/ 全部 → config.js unitycdndir"
echo "==> 清单 build/deploy/UPLOAD-MANIFEST.txt"

72
tools/deploy-local.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# 本地部署:与 CDN 相同包结构 → 同步到 scratch-gui/static/unity
#
# bash tools/deploy-local.sh
# bash tools/deploy-local.sh --skip-verify
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/build/web-desktop"
PACK_DIR="$PROJECT_DIR/build/mstest5"
CODE_HTML="${CODE_HTML:-$HOME/tfrh/001code/001code-html}"
UNITY_REF="${UNITY_REF:-$HOME/tfrh/竞赛/mstest5}"
SKIP_VERIFY=0
usage() {
cat <<'EOF'
本地部署usecdn:false / npm start
与 CDN 使用同一打包逻辑package-for-cdn目录结构一致
Build/mstest5.loader.js
StreamingAssets/aa/WebGL/*.bundle
levels-database.json(.br)
区别仅在于加载方式:本地从 /unity/ 读取CDN 从 unitycdndir 读取。
前置: Cocos Creator 已重新构建 Web Desktop含 level-prefabs 分包)
选项:
--build DIR
--code-html DIR
--unity-ref DIR Unity 参考包(默认 ~/tfrh/竞赛/mstest5
--skip-verify
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--build) BUILD_DIR="$2"; shift 2 ;;
--code-html) CODE_HTML="$2"; shift 2 ;;
--unity-ref) UNITY_REF="$2"; shift 2 ;;
--skip-verify) SKIP_VERIFY=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage; exit 1 ;;
esac
done
if [[ "$SKIP_VERIFY" -eq 0 ]]; then
bash "$SCRIPT_DIR/verify-split-build.sh" "$BUILD_DIR"
fi
BUILD_DIR="$(cd "$BUILD_DIR" && pwd)"
UNITY_REF="$(cd "$UNITY_REF" && pwd)"
UNITY_STATIC="$CODE_HTML/scratch-gui/static/unity"
if [[ ! -f "$UNITY_REF/index.html" || ! -f "$UNITY_REF/StreamingAssets/aa/catalog.json" ]]; then
echo "错误: Unity 参考包无效: $UNITY_REF" >&2
exit 1
fi
echo "==> [1/2] 打包(与 CDN 相同结构)"
node "$SCRIPT_DIR/package-for-cdn.js" "$BUILD_DIR" "$PACK_DIR" "$UNITY_REF"
echo "==> [2/2] 导入 static/unity"
bash "$SCRIPT_DIR/sync-unity-package-to-static.sh" "$PACK_DIR" "$UNITY_STATIC"
echo ""
echo "==> 完成"
echo " cd \"$CODE_HTML/scratch-gui\" && npm start"
echo " http://localhost:8601/editor.html 或 cocos-smoke.htmlCmd+Shift+R"
echo ""
echo "config.js: usecdn: false"

246
tools/deploy-to-001code.sh Executable file
View File

@@ -0,0 +1,246 @@
#!/bin/bash
# Cocos Web 构建 → 001code-html 可用的 unity 静态资源 + CDN 上传清单
#
# 001code-html 加载约定scratch-gui/src/components/stage-unity/stage-unity.jsx:
# 本地 loader: /unity/Build/mstest5.loader.js (不走 CDN须放 static/unity/Build/
# CDN 资源: {unitycdndir}/Build/*.br
# {unitycdndir}/StreamingAssets/aa/...
#
# 用法:
# bash tools/deploy-to-001code.sh
# bash tools/deploy-to-001code.sh --build build/web-desktop
# bash tools/deploy-to-001code.sh --code-html /path/to/001code-html
# bash tools/deploy-to-001code.sh --skip-pack # 仅用已有 build/mstest5 同步
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/build/web-desktop"
PACK_DIR="$PROJECT_DIR/build/mstest5"
CODE_HTML="/Users/liuyufei/tfrh/001code/001code-html"
UNITY_STATIC=""
UNITY_REF=""
CDN_BASE=""
SKIP_PACK=0
DRY_RUN=0
CDN_UPLOAD_ONLY=0
CDN_PURE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--build) BUILD_DIR="$2"; shift 2 ;;
--pack) PACK_DIR="$2"; shift 2 ;;
--code-html) CODE_HTML="$2"; shift 2 ;;
--unity-static) UNITY_STATIC="$2"; shift 2 ;;
--unity-ref) UNITY_REF="$2"; shift 2 ;;
--cdn-base) CDN_BASE="$2"; shift 2 ;;
--skip-pack) SKIP_PACK=1; shift ;;
--cdn-upload-only) CDN_UPLOAD_ONLY=1; shift ;;
--cdn-pure) CDN_PURE=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help)
cat <<'EOF'
Usage: deploy-to-001code.sh [options]
将 Cocos 包部署到 001code-html/scratch-gui/static/unity/,并生成 CDN 上传清单。
选项:
--build DIR Cocos Web Desktop 构建目录(默认 build/web-desktop
--pack DIR package-for-cdn 输出目录(默认 build/mstest5
--code-html DIR 001code-html 根目录
--unity-static DIR 覆盖 static/unity 目标(默认 scratch-gui/static/unity
--unity-ref DIR catalog 参考包(默认 static/unity否则 竞赛/mstest5
--cdn-base URL 覆盖 CDN 根 URL默认从 config.js 读取 unitycdndir
--skip-pack 跳过打包,直接同步已有 --pack 目录
--cdn-upload-only 只打包运行时包 + 清单,不同步 static/unity
--cdn-pure 不同步/不解压到 static/unity纯 CDNloader 也走 OSS
--dry-run 只打印将执行的 rsync不写入
步骤:
1. package-for-cdn以 001code static/unity 的 catalog 为参考)
2. rsync Build/、StreamingAssets/ → scratch-gui/static/unity/
3. 生成 build/deploy-001code-cdn-manifest.txt
部署后请在 001code-html/scratch-gui 执行 npm run build或 npm start 本地验证。
EOF
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
CODE_HTML="$(cd "$CODE_HTML" 2>/dev/null && pwd || true)"
if [[ -z "$CODE_HTML" || ! -d "$CODE_HTML/scratch-gui" ]]; then
echo "Error: 001code-html 不存在: $CODE_HTML"
exit 1
fi
if [[ -z "$UNITY_STATIC" ]]; then
UNITY_STATIC="$CODE_HTML/scratch-gui/static/unity"
fi
UNITY_STATIC="$(cd "$UNITY_STATIC" 2>/dev/null && pwd || true)"
if [[ -z "$UNITY_STATIC" ]]; then
echo "Error: static/unity 不存在,请先创建: $CODE_HTML/scratch-gui/static/unity"
exit 1
fi
mkdir -p "$UNITY_STATIC/Build" "$UNITY_STATIC/StreamingAssets"
# package-for-cdn 需要完整 Unity 参考包index.html + TemplateData + catalog
# static/unity 是 Cocos 扁平运行包,仅有 catalog 时不能作为参考
DEFAULT_UNITY_REF="${UNITY_REF:-$HOME/tfrh/竞赛/mstest5}"
UNITY_REF_DIR="$(cd "$DEFAULT_UNITY_REF" 2>/dev/null && pwd || true)"
if [[ -z "$UNITY_REF" && -f "$UNITY_STATIC/index.html" && -f "$UNITY_STATIC/StreamingAssets/aa/catalog.json" ]]; then
UNITY_REF_DIR="$UNITY_STATIC"
echo ">>> catalog 参考: static/unity完整 Unity 包)"
elif [[ -f "$UNITY_REF_DIR/StreamingAssets/aa/catalog.json" ]]; then
echo ">>> catalog 参考: $UNITY_REF_DIR"
else
echo "Error: 缺少 Unity 参考包(须含 index.html 与 StreamingAssets/aa/catalog.json" >&2
echo " 默认路径: $DEFAULT_UNITY_REF" >&2
echo " 可指定: bash tools/deploy-cdn.sh 所在脚本加 --unity-ref /path/to/mstest5" >&2
exit 1
fi
if [[ "$SKIP_PACK" -eq 0 ]]; then
BUILD_DIR="$(cd "$BUILD_DIR" 2>/dev/null && pwd || true)"
if [[ -z "$BUILD_DIR" || ! -f "$BUILD_DIR/index.html" ]]; then
echo "Error: 请先 Cocos Creator 构建 Web Desktop: $BUILD_DIR"
exit 1
fi
if [[ ! -f "$UNITY_REF_DIR/StreamingAssets/aa/catalog.json" ]]; then
echo "Error: 缺少参考 catalog: $UNITY_REF_DIR/StreamingAssets/aa/catalog.json"
echo " 请指定 --unity-ref 指向含 StreamingAssets/aa/catalog.json 的 Unity 包"
exit 1
fi
echo ">>> [1/3] 打包 (unity-ref = $UNITY_REF_DIR)"
node "$SCRIPT_DIR/package-for-cdn.js" "$BUILD_DIR" "$PACK_DIR" "$UNITY_REF_DIR"
else
PACK_DIR="$(cd "$PACK_DIR" 2>/dev/null && pwd || true)"
if [[ -z "$PACK_DIR" || ! -f "$PACK_DIR/Build/mstest5.loader.js" ]]; then
echo "Error: --skip-pack 但打包目录无效: $PACK_DIR"
exit 1
fi
echo ">>> [1/3] 跳过打包,使用: $PACK_DIR"
fi
PACK_DIR="$(cd "$PACK_DIR" && pwd)"
if [[ -z "$CDN_BASE" ]]; then
CONFIG_JS="$CODE_HTML/scratch-gui/src/playground/config.js"
if [[ -f "$CONFIG_JS" ]]; then
CDN_BASE="$(node -e "
const fs=require('fs');
const t=fs.readFileSync(process.argv[1],'utf8');
const m=t.match(/unitycdndir:\s*['\"]([^'\"]+)['\"]/);
if(!m){process.exit(1)}
console.log(m[1]);
" "$CONFIG_JS" 2>/dev/null || true)"
fi
fi
CDN_BASE="${CDN_BASE:-https://oss.eanic.cn/001_code_unity_res_CHANGE_ME}"
CDN_DEPLOY_DIR="$PROJECT_DIR/build/deploy"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo ">>> [1b] [dry-run] 生成上传清单 → $CDN_DEPLOY_DIR"
else
echo ">>> [1b] 生成上传清单(运行时包不复制: $PACK_DIR"
MANIFEST_ARGS=("$PACK_DIR" --manifest-dir "$CDN_DEPLOY_DIR")
[[ -n "$CDN_BASE" ]] && MANIFEST_ARGS+=(--cdn-base "$CDN_BASE")
node "$SCRIPT_DIR/write-deploy-manifest.js" "${MANIFEST_ARGS[@]}"
fi
if [[ "$CDN_UPLOAD_ONLY" -eq 1 ]]; then
echo ""
echo "完成(仅运行时包 + 清单)。"
echo " 运行时包: $PACK_DIR"
echo " 清单: $CDN_DEPLOY_DIR/UPLOAD-MANIFEST.txt"
exit 0
fi
if [[ "$CDN_PURE" -eq 1 ]]; then
echo ">>> [2/3] 跳过 static/unity 同步(--cdn-pure仅 OSS"
else
echo ">>> [2/3] 同步到 static/unity与运行时包相同"
if [[ "$DRY_RUN" -eq 1 ]]; then
DRY_RUN=1 bash "$SCRIPT_DIR/sync-unity-package-to-static.sh" "$PACK_DIR" "$UNITY_STATIC"
else
bash "$SCRIPT_DIR/sync-unity-package-to-static.sh" "$PACK_DIR" "$UNITY_STATIC"
fi
fi
MANIFEST="$PROJECT_DIR/build/deploy-001code-cdn-manifest.txt"
mkdir -p "$(dirname "$MANIFEST")"
echo ">>> [3/3] 生成 CDN 上传清单: $MANIFEST"
{
echo "# 001code-html CDN 上传清单"
echo "# 生成时间: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "# CDN 根目录 (unitycdndir): $CDN_BASE"
echo "#"
echo "# 上传规则: 将 build/mstest5/ 全部内容上传到 OSS unitycdndir"
echo ""
echo "## CDN: Build/unitycdnbuilddir = unitycdndir + /Build含 loader.js"
for f in "$PACK_DIR/Build"/*; do
[[ -f "$f" ]] || continue
base="$(basename "$f")"
echo "local: Build/$base"
echo "cdn: $CDN_BASE/Build/$base"
echo ""
done
echo "## CDN: StreamingAssets/unitycdndir + /StreamingAssets"
while IFS= read -r -d '' f; do
rel="${f#$PACK_DIR/StreamingAssets/}"
echo "local: StreamingAssets/$rel"
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
if [[ -f "$PACK_DIR/$db" ]]; then
echo "local: $db"
echo "cdn: $CDN_BASE/$db"
echo ""
fi
done
echo "## 001code config.js 当前配置"
echo "unitycdndir: $CDN_BASE"
echo "unitycdnbuilddir: $CDN_BASE/Build"
echo "streamingAssetsUrl (CDN): $CDN_BASE/StreamingAssets"
echo "loaderUrl (CDN): $CDN_BASE/Build/mstest5.loader.js"
echo ""
echo "## 验证(上传后)"
echo "curl -I \"$CDN_BASE/Build/mstest5.loader.js\""
echo "curl -I \"$CDN_BASE/StreamingAssets/aa/catalog.json\""
python3 - <<'PY' "$PACK_DIR/StreamingAssets/aa/catalog.json" "$CDN_BASE"
import json, sys
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}"')
PY
} > "$MANIFEST"
echo ""
echo "完成。"
if [[ "$CDN_PURE" -eq 1 ]]; then
echo " 模式: 纯 CDN未写入 static/unity"
else
echo " 本地 static: $UNITY_STATIC"
fi
echo " CDN 运行时包: $PACK_DIR"
echo " CDN 清单: $CDN_DEPLOY_DIR/UPLOAD-MANIFEST.txt"
echo ""
echo "下一步:"
echo " cd \"$CODE_HTML/scratch-gui\""
if [[ "$CDN_PURE" -eq 1 ]]; then
echo " config.js: usecdn:true → npm run build → 部署 dist/(无需 static/unity 游戏资源)"
else
echo " npm start # usecdn:false 本地测 static/unity"
echo " npm run build # usecdn:true 生产构建"
fi
echo ""
echo "生产 CDN: 上传 $PACK_DIR 全部内容到 OSS见 deploy/UPLOAD-MANIFEST.txt"

189
tools/export_all_levels.py Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
从 Unity 全量导出关卡到单一 JSON 数据库(方便后期增删查改)。
数据来源:
- Assets/Scripts/Core/Levels*.cs → spawns / boundary / levelPath
- Assets/Prefabs/Level/LevelN.prefab → Ground / Border Tilemap
用法:
python3 tools/export_all_levels.py \\
--unity-root "/path/to/主站" \\
--output assets/level-data/levels-database.json
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from export_unity_levels import (
LEVEL_RE,
BOUND_RE,
SPAWN_RE,
POS_RE,
PATH_RE,
PDIR_RE,
VDIR_RE,
kind_from_path,
parse_spawns,
build_border_cache,
)
from export_unity_prefab_maps import parse_level_prefab
from level_id import LEVEL_ID_BASE, normalize_level_id, prefab_resource_path
LEVEL_PATH_RE = re.compile(
r'levelPath\s*=\s*"?([^",\s]+Level\d+\.prefab)"?', re.I
)
def parse_level_block(chunk: str, lid: int) -> dict:
bound = BOUND_RE.search(chunk)
bx, by = (10, 10)
if bound:
bx, by = int(bound.group(1)), int(bound.group(2))
path_m = LEVEL_PATH_RE.search(chunk)
level_path = path_m.group(1) if path_m else f"Assets/Prefabs/Level/Level{lid}.prefab"
ext_id = normalize_level_id(lid)
return {
"levelID": ext_id,
"boundary": {"x": bx, "y": by},
"spawns": parse_spawns(chunk),
"unityPrefab": level_path.replace("\\", "/"),
"cocosPrefab": prefab_resource_path(ext_id),
}
def parse_levels_cs_file(path: Path) -> dict[int, dict]:
text = path.read_text(encoding="utf-8")
levels: dict[int, dict] = {}
matches = list(LEVEL_RE.finditer(text))
for i, m in enumerate(matches):
lid = int(m.group(1))
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
levels[lid] = parse_level_block(text[start:end], lid)
return levels
def parse_all_levels_cs(core_dir: Path) -> dict[int, dict]:
merged: dict[int, dict] = {}
for cs in sorted(core_dir.glob("Levels*.cs")):
part = parse_levels_cs_file(cs)
for lid, L in part.items():
if lid in merged:
print(f"warn: duplicate level id {lid} in {cs.name}, keep first", file=sys.stderr)
continue
merged[lid] = L
return merged
def prefab_path_for_level(L: dict, prefab_dir: Path) -> Path | None:
rel = L.get("unityPrefab", "")
if rel:
name = Path(rel).name
p = prefab_dir / name
if p.is_file():
return p
p = prefab_dir / f"Level{L['levelID']}.prefab"
return p if p.is_file() else None
def merge_prefab_maps(L: dict, prefab_dir: Path) -> None:
p = prefab_path_for_level(L, prefab_dir)
if not p:
bx, by = L["boundary"]["x"], L["boundary"]["y"]
ring = build_border_cache({L["levelID"]: {**L, "borderKey": f"{bx},{by}"}})
L["border"] = ring[f"{bx},{by}"]
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"]
L["_mapSource"] = "unity_prefab"
def strip_internal(L: dict) -> dict:
out = {
"levelID": L["levelID"],
"boundary": L["boundary"],
"spawns": L["spawns"],
}
if L.get("unityPrefab"):
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"]
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--unity-root", required=True, help="Unity 项目根目录(含 Assets")
ap.add_argument("--output", required=True, help="输出 levels-database.json")
ap.add_argument("--limit", type=int, default=0, help="仅导出前 N 个关卡(调试用)")
ap.add_argument("--skip-prefab-maps", action="store_true", help="不解析 Unity prefab 瓦片(快,地图由 Cocos 预制体承担)")
args = ap.parse_args()
unity = Path(args.unity_root)
core = unity / "Assets/Scripts/Core"
prefab_dir = unity / "Assets/Prefabs/Level"
if not core.is_dir():
print(f"Core dir not found: {core}", file=sys.stderr)
sys.exit(1)
print("Parsing Levels*.cs …")
levels = parse_all_levels_cs(core)
ids = sorted(levels.keys())
if args.limit > 0:
ids = ids[: args.limit]
levels = {k: levels[k] for k in ids}
print(f"Level definitions: {len(levels)}")
from_prefab = 0
from_ring = 0
if args.skip_prefab_maps:
print("Skipping prefab Tilemaps (--skip-prefab-maps)")
else:
print("Merging prefab Tilemaps …")
for i, lid in enumerate(ids):
if (i + 1) % 200 == 0:
print(f"{i + 1}/{len(ids)}")
merge_prefab_maps(levels[lid], prefab_dir)
if levels[lid].get("_mapSource") == "unity_prefab":
from_prefab += 1
else:
from_ring += 1
payload = {
"version": 1,
"generatedAt": datetime.now(timezone.utc).isoformat(),
"source": "Unity Levels*.cs + Assets/Prefabs/Level/*.prefab",
"stats": {
"total": len(levels),
"withPrefabTilemap": from_prefab,
"withBoundaryRing": from_ring,
},
"levelIdBase": LEVEL_ID_BASE,
"levels": {str(levels[lid]["levelID"]): strip_internal(levels[lid]) for lid in ids},
}
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Wrote {out} ({out.stat().st_size // 1024} KB)")
print(f" prefab tilemap: {from_prefab}, fallback ring: {from_ring}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
从 Cocos 工程导出 levels-database.json权威数据源
地图 / 主题level-prefabs/Level{id}.prefab 上的 LevelMapDatagroundJson / borderJson / theme
实体 spawn / boundary保留 assets/level-data/levels-database.json 中已有条目(关卡编辑器维护)
Unity 主站仅作 ID 对照参考,不参与本导出。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from level_id import LEVEL_ID_BASE, level_db_path, normalize_level_id, prefab_resource_path
LEVEL_MAP_TYPE = "d4e5fanuMlNDh8qO0xdbn+K"
PREFAB_NAME_RE = re.compile(r"^Level(\d+)\.prefab$", re.I)
def parse_prefab_map_data(prefab_path: Path) -> dict | None:
try:
objs = json.loads(prefab_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
print(f"warn: skip {prefab_path.name}: {e}", file=sys.stderr)
return None
if not isinstance(objs, list):
return None
for obj in objs:
if not isinstance(obj, dict):
continue
if obj.get("__type__") != LEVEL_MAP_TYPE and "groundJson" not in obj:
continue
if "groundJson" not in obj and "levelID" not in obj:
continue
level_id = int(obj.get("levelID") or 0)
ground = {}
border = {}
try:
ground = json.loads(obj.get("groundJson") or "{}")
except json.JSONDecodeError:
pass
try:
border = json.loads(obj.get("borderJson") or "{}")
except json.JSONDecodeError:
pass
theme = str(obj.get("theme") or "").strip() or None
return {
"levelID": level_id,
"ground": ground if isinstance(ground, dict) else {},
"border": border if isinstance(border, dict) else {},
"theme": theme,
}
return None
def load_existing_db(path: Path) -> dict:
if not path.is_file():
return {"levels": {}}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"levels": {}}
def merge_entry(level_id: int, map_data: dict | None, prev: dict | None) -> dict:
ext_id = normalize_level_id(level_id)
out: dict = {
"levelID": ext_id,
"boundary": (prev or {}).get("boundary") or {"x": 20, "y": 20},
"spawns": list((prev or {}).get("spawns") or []),
"cocosPrefab": prefab_resource_path(ext_id),
}
if prev and prev.get("unityPrefab"):
out["unityPrefab"] = prev["unityPrefab"]
if map_data:
if map_data.get("ground"):
out["ground"] = map_data["ground"]
if map_data.get("border"):
out["border"] = map_data["border"]
if map_data.get("theme"):
out["theme"] = map_data["theme"]
if map_data.get("levelID") and map_data["levelID"] > 0:
out["levelID"] = normalize_level_id(map_data["levelID"])
elif prev:
for k in ("ground", "border", "theme", "entityTextures"):
if prev.get(k) is not None:
out[k] = prev[k]
return out
def export_db(project: Path, output: Path, prefab_dir: Path, level_id: int | None = None) -> dict:
existing = load_existing_db(output)
prev_levels = existing.get("levels") or {}
levels: dict[str, dict] = dict(prev_levels)
prefab_files = sorted(prefab_dir.glob("Level*.prefab"), key=lambda p: p.name)
if level_id is not None:
target = normalize_level_id(level_id)
prefab_files = [
p for p in prefab_files
if (m := PREFAB_NAME_RE.match(p.name)) and int(m.group(1)) == target
]
if not prefab_files and level_id is None:
print(f"error: no prefabs in {prefab_dir}", file=sys.stderr)
sys.exit(1)
if level_id is not None and not prefab_files:
print(f"error: prefab Level{normalize_level_id(level_id)} not found in {prefab_dir}", file=sys.stderr)
sys.exit(1)
for pf in prefab_files:
m = PREFAB_NAME_RE.match(pf.name)
if not m:
continue
lid = int(m.group(1))
map_data = parse_prefab_map_data(pf)
prev = prev_levels.get(str(lid)) or prev_levels.get(str(normalize_level_id(lid)))
levels[str(normalize_level_id(lid))] = merge_entry(lid, map_data, prev)
if level_id is None:
# 保留仅有 DB 条目、尚无预制体的关卡(编辑器先写 spawns
for key, prev in prev_levels.items():
if key not in levels:
try:
lid = int(key)
except ValueError:
continue
levels[key] = merge_entry(lid, None, prev)
prev_stats = existing.get("stats") or {}
total_with_maps = sum(
1 for v in levels.values() if (v.get("ground") or v.get("border"))
)
return {
"version": 2,
"generatedAt": datetime.now(timezone.utc).isoformat(),
"source": "Cocos level-prefabs LevelMapData + levels-database spawns",
"levelIdBase": LEVEL_ID_BASE,
"stats": {
"total": len(levels),
"withPrefabTilemap": total_with_maps,
"withBoundaryRing": prev_stats.get("withBoundaryRing", 0),
},
"levels": dict(sorted(levels.items(), key=lambda kv: int(kv[0]))),
}
def resolve_prefab_dir(project: Path) -> Path:
"""迁移后预制体在 bundle-level-prefabs/level-prefabs否则仍在 resources 下。"""
bundle = project / "assets/bundle-level-prefabs/level-prefabs"
legacy = project / "assets/resources/level-prefabs"
if bundle.is_dir():
return bundle
return legacy
def main():
ap = argparse.ArgumentParser(description="从 Cocos 预制体导出 levels-database.json")
ap.add_argument(
"--project",
default=str(Path(__file__).resolve().parent.parent),
help="Cocos 工程根目录",
)
ap.add_argument(
"--output",
default="",
help="输出 JSON默认 assets/level-data/levels-database.json",
)
ap.add_argument(
"--prefab-dir",
default="",
help="关卡预制体目录(默认自动检测 bundle-level-prefabs 或 resources",
)
ap.add_argument(
"--level-id",
type=int,
default=None,
help="仅导出/合并指定关卡(增量更新 levels-database.json",
)
args = ap.parse_args()
project = Path(args.project)
output = Path(args.output) if args.output else level_db_path(project)
prefab_dir = Path(args.prefab_dir) if args.prefab_dir else resolve_prefab_dir(project)
data = export_db(project, output, prefab_dir, args.level_id)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Exported {data['stats']['total']} levels -> {output}")
print(f" with prefab tilemap: {data['stats']['withPrefabTilemap']}")
if __name__ == "__main__":
main()

View File

@@ -15,6 +15,8 @@ import re
import sys
from pathlib import Path
from export_unity_prefab_maps import parse_level_prefab
DIR = {
"Direction.North": "Direction.North",
"Direction.East": "Direction.East",
@@ -47,13 +49,19 @@ def kind_from_path(path: str) -> str:
return "vehicle"
if "enemy" in p:
return "enemy"
if "nprop" in p:
return "prop_decor"
if "prop" in p:
return "prop"
return "prop_decor"
def prop_placement_from_path(path: str) -> str | None:
if "nprop" in path.lower():
return "ground"
if "prop" in path.lower():
return "block"
return None
def parse_spawns(block: str) -> list[dict]:
spawns = []
for m in SPAWN_RE.finditer(block):
@@ -68,6 +76,9 @@ def parse_spawns(block: str) -> list[dict]:
"y": int(pm.group(2)),
"kind": kind_from_path(path),
}
placement = prop_placement_from_path(path)
if placement and item["kind"] == "prop":
item["propPlacement"] = placement
pdir = PDIR_RE.search(body)
vdir = VDIR_RE.search(body)
if pdir:
@@ -114,33 +125,44 @@ def spawn_to_ts(s: dict, indent: str) -> str:
parts.append(f"playerDirection: {s['playerDirection']}")
if "vehicleDirection" in s:
parts.append(f"vehicleDirection: {s['vehicleDirection']}")
if s.get("propPlacement"):
parts.append(f"propPlacement: '{s['propPlacement']}'")
return indent + "{ " + ", ".join(parts) + " }"
def emit_ts(levels: dict[int, dict], border_cache: dict[str, dict]) -> str:
def emit_ts(levels: dict[int, dict], export_const: str = "LEVELS_600") -> str:
lines = [
"/* AUTO-GENERATED by tools/export_unity_levels.py — DO NOT EDIT */",
"/* spawns 来自 Levels*.csground/border 来自 Assets/Prefabs/Level/LevelN.prefab Tilemap */",
"import { Direction } from '../core/Define';",
"import { LevelConfig, SpawnConfig } from './LevelTypes';",
"import { LevelConfig } from './LevelTypes';",
"",
"const BORDER_CACHE: Record<string, Record<string, boolean>> = " + json.dumps(border_cache, separators=(',', ':')) + ";",
"",
"function withBorder(key: string, cfg: Omit<LevelConfig, 'border'>): LevelConfig {",
" return { ...cfg, border: BORDER_CACHE[key] };",
"}",
"",
"export const LEVELS_600: Record<number, LevelConfig> = {",
f"export const {export_const}: Record<number, LevelConfig> = {{",
]
for lid in sorted(levels.keys()):
L = levels[lid]
lines.append(f" {lid}: withBorder('{L['borderKey']}', {{")
lines.append(f" {lid}: {{")
lines.append(f" levelID: {lid},")
lines.append(f" boundary: {{ x: {L['boundary']['x']}, y: {L['boundary']['y']} }},")
lines.append(
f" boundary: {{ x: {L['boundary']['x']}, y: {L['boundary']['y']} }},"
)
if L.get("ground"):
lines.append(
" ground: "
+ json.dumps(L["ground"], ensure_ascii=False, separators=(",", ": "))
+ ","
)
if L.get("border"):
lines.append(
" border: "
+ json.dumps(L["border"], ensure_ascii=False, separators=(",", ": "))
+ ","
)
lines.append(" spawns: [")
for s in L["spawns"]:
lines.append(spawn_to_ts(s, " ") + ",")
lines.append(" ],")
lines.append(" }),")
lines.append(" },")
lines.append("};")
lines.append("")
return "\n".join(lines)
@@ -167,21 +189,49 @@ def main():
ap.add_argument("--input", required=True, help="Unity Levels*.cs path")
ap.add_argument("--output", required=True, help="Output .ts path")
ap.add_argument("--border-cache", default="", help="Optional border cache json")
ap.add_argument("--limit", type=int, default=0, help="Max levels to export (0 = all)")
ap.add_argument("--export-const", default="LEVELS_600", help="TS export const name")
ap.add_argument(
"--prefab-dir",
default="",
help="Unity Assets/Prefabs/Level 目录,合并 LevelN.prefab 的 Tilemap",
)
args = ap.parse_args()
text = Path(args.input).read_text(encoding="utf-8")
levels = parse_file(text)
if args.limit > 0:
sorted_ids = sorted(levels.keys())[: args.limit]
levels = {k: levels[k] for k in sorted_ids}
if not levels:
print("No levels parsed", file=sys.stderr)
sys.exit(1)
border_cache = build_border_cache(levels)
prefab_dir = Path(args.prefab_dir) if args.prefab_dir else None
merged_prefab = 0
if prefab_dir:
for lid, L in levels.items():
maps = parse_level_prefab(prefab_dir / f"Level{lid}.prefab")
if maps:
L["ground"] = maps.get("ground", {})
L["border"] = maps.get("border", {})
merged_prefab += 1
else:
ring = build_border_cache({lid: L})[L["borderKey"]]
L["border"] = ring
print(f"Merged Tilemap from {merged_prefab} prefabs")
else:
border_cache = build_border_cache(levels)
for L in levels.values():
L["border"] = border_cache[L["borderKey"]]
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(emit_ts(levels, border_cache), encoding="utf-8")
out.write_text(emit_ts(levels, args.export_const), encoding="utf-8")
if args.border_cache:
Path(args.border_cache).write_text(json.dumps(border_cache, indent=2), encoding="utf-8")
cache = {str(lid): L.get("border", {}) for lid, L in levels.items()}
Path(args.border_cache).write_text(json.dumps(cache, indent=2), encoding="utf-8")
print(f"Exported {len(levels)} levels -> {out}")

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""从 Unity Level{N}.prefab 解析 TilemapGround / Border格子数据。"""
from __future__ import annotations
import re
from pathlib import Path
def parse_tilemap_layer(text: str, layer_name: str) -> dict[str, int]:
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 {}
def parse_level_prefab(prefab_path: Path) -> dict:
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:
return {}
ground = {
k: ("JumpBlock" if ti == 1 else "Baseblock") for k, ti in g_raw.items()
}
border = {k: True for k in b_raw}
return {"ground": ground, "border": border}

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""将地块贴图 .meta 设为 sprite-frame对齐 Unity pivot按透明边裁剪以贴满格子。"""
from __future__ import annotations
import argparse
import json
import re
import struct
import uuid
from pathlib import Path
TILE_GLOBS = [
"assets/resources/textures/*/Baseblock.png",
"assets/resources/textures/*/JumpBlock.png",
"assets/resources/textures/*/WallBlock.png",
"assets/resources/textures/*/Decor23.png",
"assets/resources/textures/*/kuai11.png",
"assets/resources/textures/*/素材切图-23.png",
"assets/resources/textures/*/素材切图2-23.png",
]
COCOS_TO_UNITY = {
"default": "Level",
"silu": "silu",
"snow": "snow",
"sanxing": "sanxing",
"chinese": "Chinese",
"numMan": "numMan",
"redarmy": "redArmy",
"redArmy": "redArmy",
}
def png_size(path: Path) -> tuple[int, int]:
with path.open("rb") as f:
f.read(16)
return struct.unpack(">II", f.read(8))
def alpha_bbox(png_path: Path) -> tuple[int, int, int, int] | None:
try:
from PIL import Image
except ImportError:
return None
try:
img = Image.open(png_path).convert("RGBA")
box = img.getbbox()
if not box:
return None
return box
except Exception:
return None
def read_unity_pivot(unity_tex: Path, theme_folder: str, tile_name: str) -> tuple[float, float]:
theme_dir = unity_tex if theme_folder == "Level" else unity_tex / theme_folder
meta_path = theme_dir / f"{tile_name}.png.meta"
if not meta_path.is_file() and tile_name == "Decor23":
meta_path = theme_dir / "素材切图-23.png.meta"
if not meta_path.is_file():
return 0.5, 0.92
text = meta_path.read_text(encoding="utf-8")
m = re.search(r"spritePivot:\s*\{x:\s*([^,]+),\s*y:\s*([^}]+)\}", text)
if not m:
return 0.5, 0.92
return float(m.group(1)), float(m.group(2))
def resolve_trim(
png_path: Path,
full_w: int,
full_h: int,
pivot_x: float,
pivot_y: float,
) -> dict:
"""按透明边裁剪,并换算 Unity pivot 到裁剪后 sprite。"""
bbox = alpha_bbox(png_path)
if not bbox:
return {
"trimType": "none",
"trimX": 0,
"trimY": 0,
"width": full_w,
"height": full_h,
"rawWidth": full_w,
"rawHeight": full_h,
"offsetX": 0,
"offsetY": 0,
"pivotX": pivot_x,
"pivotY": pivot_y,
}
left, top, right, bottom = bbox
trim_w = right - left
trim_h = bottom - top
if trim_w <= 0 or trim_h <= 0 or (trim_w >= full_w and trim_h >= full_h):
return {
"trimType": "none",
"trimX": 0,
"trimY": 0,
"width": full_w,
"height": full_h,
"rawWidth": full_w,
"rawHeight": full_h,
"offsetX": 0,
"offsetY": 0,
"pivotX": pivot_x,
"pivotY": pivot_y,
}
pivot_bottom = pivot_y * full_h
trim_bottom = full_h - bottom
pivot_bottom_trim = pivot_bottom - trim_bottom
new_pivot_y = max(0.0, min(1.0, pivot_bottom_trim / trim_h))
new_pivot_x = max(0.0, min(1.0, (pivot_x * full_w - left) / trim_w))
return {
"trimType": "custom",
"trimX": left,
"trimY": top,
"width": trim_w,
"height": trim_h,
"rawWidth": full_w,
"rawHeight": full_h,
"offsetX": 0,
"offsetY": 0,
"pivotX": round(new_pivot_x, 4),
"pivotY": round(new_pivot_y, 4),
}
def ensure_image_meta(meta_path: Path, display_name: str) -> None:
if meta_path.is_file():
return
base_uuid = str(uuid.uuid4())
meta_path.write_text(
json.dumps(
{
"ver": "1.0.27",
"importer": "image",
"imported": False,
"uuid": base_uuid,
"files": [".json", ".png"],
"subMetas": {
"6c48a": {
"importer": "texture",
"uuid": f"{base_uuid}@6c48a",
"displayName": display_name,
"id": "6c48a",
"name": "texture",
"userData": {
"imageUuidOrDatabaseUri": base_uuid,
"isUuid": True,
"visible": False,
},
"ver": "1.0.22",
"imported": False,
"files": [".json"],
"subMetas": {},
}
},
"userData": {"type": "texture", "redirect": f"{base_uuid}@6c48a"},
},
indent=2,
),
encoding="utf-8",
)
def patch_meta(meta_path: Path, png_path: Path, trim: dict) -> None:
ensure_image_meta(meta_path, meta_path.stem.replace(".png", ""))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
base_uuid = meta["uuid"]
tex_uuid = f"{base_uuid}@6c48a"
sf_uuid = f"{base_uuid}@f9941"
meta["userData"] = {
"type": "sprite-frame",
"redirect": tex_uuid,
"hasAlpha": True,
"fixAlphaTransparencyArtifacts": False,
}
meta["subMetas"]["f9941"] = {
"ver": "1.0.12",
"importer": "sprite-frame",
"uuid": sf_uuid,
"imported": True,
"files": [".json"],
"subMetas": {},
"userData": {
"wrapModeS": "clamp-to-edge",
"wrapModeT": "clamp-to-edge",
"minfilter": "linear",
"magfilter": "linear",
"premultiplyAlpha": False,
"generateMipmap": False,
"anisotropy": 1,
"trimType": trim["trimType"],
"trimThreshold": 1,
"rotated": False,
"offsetX": trim["offsetX"],
"offsetY": trim["offsetY"],
"trimX": trim["trimX"],
"trimY": trim["trimY"],
"width": trim["width"],
"height": trim["height"],
"rawWidth": trim["rawWidth"],
"rawHeight": trim["rawHeight"],
"borderTop": 0,
"borderBottom": 0,
"borderLeft": 0,
"borderRight": 0,
"isUuid": True,
"imageUuidOrDatabaseUri": tex_uuid,
"atlasUuid": "",
"mipfilter": "none",
"packable": True,
"pixelsToUnit": 100,
"pivotX": trim["pivotX"],
"pivotY": trim["pivotY"],
"meshType": 0,
},
"displayName": meta_path.stem,
"id": "f9941",
"name": "spriteFrame",
}
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
rel = meta_path.relative_to(meta_path.parents[4])
print(
f" patched {rel} -> draw {trim['width']}x{trim['height']} "
f"(raw {trim['rawWidth']}x{trim['rawHeight']}) pivot=({trim['pivotX']},{trim['pivotY']})"
)
def collect_tile_pngs(root: Path, themes: set[str] | None) -> list[Path]:
found: set[Path] = set()
for pattern in TILE_GLOBS:
for p in root.glob(pattern):
if not p.is_file():
continue
if themes:
theme = p.parent.name
if theme not in themes:
continue
found.add(p)
return sorted(found)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--unity-root", help="Unity 项目根目录,用于读取 spritePivot")
ap.add_argument("--themes", help="仅处理指定 Cocos 主题目录,逗号分隔,如 snow")
args = ap.parse_args()
root = Path(__file__).resolve().parent.parent
theme_filter = {t.strip() for t in args.themes.split(",") if t.strip()} if args.themes else None
unity_tex = Path(args.unity_root) / "Assets" / "Texture" if args.unity_root else None
pngs = collect_tile_pngs(root, theme_filter)
for png in pngs:
meta = png.with_suffix(".png.meta")
if not png.is_file():
print(f" skip missing {png.relative_to(root)}")
continue
w, h = png_size(png)
tile_name = png.stem
cocos_theme = png.parent.name
if unity_tex and unity_tex.is_dir():
unity_folder = COCOS_TO_UNITY.get(cocos_theme, cocos_theme)
pivot_x, pivot_y = read_unity_pivot(unity_tex, unity_folder, tile_name)
else:
pivot_x, pivot_y = 0.5, 0.92
trim = resolve_trim(png, w, h, pivot_x, pivot_y)
patch_meta(meta, png, trim)
print(f"Patched {len(pngs)} tile metas. 请在 Cocos Creator 中刷新 assets/resources/textures。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""从载具 F/B 贴图生成四向 N/E/S/W 资源E/W 为 F/B 水平镜像N/S 复制 B/F"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TEX = ROOT / "assets" / "resources" / "textures"
# theme folder -> ship 文件名前缀(不含 _F/_B
THEMES: dict[str, str] = {
"silu": "siluShip",
"sanxing": "sanxingShip",
"snow": "snowShip",
"chinese": "chineseShip",
"numMan": "numManShip",
"redArmy": "redArmyShip",
}
def flip_horizontal(src: Path, dst: Path) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
try:
subprocess.run(
["sips", "-f", "horizontal", str(src), "--out", str(dst)],
check=True,
capture_output=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
try:
from PIL import Image
Image.open(src).transpose(Image.FLIP_LEFT_RIGHT).save(dst)
except ImportError as e:
raise SystemExit(
"需要 macOS sips 或 pip install Pillow 才能生成 E/W 镜像贴图"
) from e
def copy_meta(src_meta: Path, dst_meta: Path) -> None:
if src_meta.is_file():
shutil.copy2(src_meta, dst_meta)
def generate_for_theme(folder: str, prefix: str) -> None:
base = TEX / folder
f_png = base / f"{prefix}_F.png"
b_png = base / f"{prefix}_B.png"
if not f_png.is_file() or not b_png.is_file():
print(f" skip {folder}: missing {prefix}_F/_B")
return
n_png = base / f"{prefix}_N.png"
s_png = base / f"{prefix}_S.png"
e_png = base / f"{prefix}_E.png"
w_png = base / f"{prefix}_W.png"
shutil.copy2(b_png, n_png)
shutil.copy2(f_png, s_png)
flip_horizontal(f_png, e_png)
flip_horizontal(b_png, w_png)
copy_meta(b_png.with_suffix(".png.meta"), n_png.with_suffix(".png.meta"))
copy_meta(f_png.with_suffix(".png.meta"), s_png.with_suffix(".png.meta"))
copy_meta(f_png.with_suffix(".png.meta"), e_png.with_suffix(".png.meta"))
copy_meta(b_png.with_suffix(".png.meta"), w_png.with_suffix(".png.meta"))
print(f" ok {folder}: {prefix}_{{N,E,S,W}}.png")
def main() -> int:
print(f"textures root: {TEX}")
for folder, prefix in THEMES.items():
generate_for_theme(folder, prefix)
print("done — 请在 Creator 中刷新 assets/resources/textures")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
从 Unity Assets/Texture 导入 PNG 到 Cocos assets/resources/textures/
用法:
python3 tools/import_unity_textures.py \\
--unity-root "/path/to/主站" \\
[--themes silu,snow,default]
"""
from __future__ import annotations
import argparse
import json
import shutil
import uuid
from pathlib import Path
# 与 GameController UIStyleNames 对应Unity 目录名 → Cocos 子目录)
THEME_MAP = {
"default": ".", # Assets/Texture 根目录单层 png
"silu": "silu",
"chinese": "Chinese",
"redArmy": "redarmy",
"numMan": "numMan",
"snow": "snow",
"sanxing": "sanxing",
}
# 角色 + 砖块核心文件名(各主题有则拷贝,无则跳过)
CORE_NAMES = {
"Baseblock.png",
"JumpBlock.png",
"WallBlock.png",
"player_F.png",
"player_B.png",
"ship_F.png",
"ship_B.png",
"Ship_F.png",
"Prop.png",
"nProp.png",
"kuai11.png",
"素材切图-23.png",
"素材切图2-23.png",
"Prop_kuai1.png",
"Prop_kuai2.png",
"Prop_kuai.png",
"nProp_kuai1.png",
"nProp_kuai2.png",
"nProp_kuai.png",
"Prop_Dumpling.png",
"Prop_Mooncake.png",
"Prop_Ricedumpling.png",
"Prop_Zhongzi.png",
"Prop_star.png",
"nProp_star.png",
"nProp_Dumpling.png",
"nProp_Mooncake.png",
"nProp_Ricedumpling.png",
"nProp_Zhongzi.png",
}
# 丝路:对齐 sanxing/snow 命名skin/待机正面、*Ship_F、Prop_kuai1
SILU_CANONICAL_COPIES: list[tuple[str, str]] = [
("ship_F.png", "siluShip_F.png"),
("ship_B.png", "siluShip_B.png"),
("Prop.png", "Prop_kuai1.png"),
("nProp.png", "nProp_kuai1.png"),
]
def ensure_directory_meta(meta_path: Path) -> None:
if meta_path.is_file():
return
meta_path.write_text(
json.dumps(
{
"ver": "1.2.0",
"importer": "directory",
"imported": True,
"uuid": str(uuid.uuid4()),
"files": [],
"subMetas": {},
"userData": {},
},
indent=2,
),
encoding="utf-8",
)
def ensure_image_meta(meta_path: Path, display_name: str) -> None:
if meta_path.is_file():
return
base_uuid = str(uuid.uuid4())
meta_path.write_text(
json.dumps(
{
"ver": "1.0.27",
"importer": "image",
"imported": False,
"uuid": base_uuid,
"files": [".json", ".png"],
"subMetas": {
"6c48a": {
"importer": "texture",
"uuid": f"{base_uuid}@6c48a",
"displayName": display_name,
"id": "6c48a",
"name": "texture",
"userData": {
"imageUuidOrDatabaseUri": base_uuid,
"isUuid": True,
"visible": False,
},
"ver": "1.0.22",
"imported": False,
"files": [".json"],
"subMetas": {},
}
},
"userData": {"type": "texture", "redirect": f"{base_uuid}@6c48a"},
},
indent=2,
),
encoding="utf-8",
)
def ensure_png_meta(png_path: Path) -> None:
ensure_image_meta(png_path.with_suffix(".png.meta"), png_path.stem)
def first_existing_file(*candidates: Path) -> Path | None:
for path in candidates:
if path.is_file():
return path
return None
def copy_skin_tree(src_skin: Path, dst_skin: Path) -> None:
"""将 player/skin 整树复制到主题根 skin对齐 sanxing 目录结构)。"""
if not src_skin.is_dir():
return
dst_skin.mkdir(parents=True, exist_ok=True)
ensure_directory_meta(dst_skin.parent / f"{dst_skin.name}.meta")
for src in sorted(src_skin.rglob("*")):
rel = src.relative_to(src_skin)
dst = dst_skin / rel
if src.is_dir():
dst.mkdir(parents=True, exist_ok=True)
ensure_directory_meta(dst.parent / f"{dst.name}.meta")
continue
if src.suffix.lower() != ".png":
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
ensure_png_meta(dst)
def normalize_silu_assets(theme_dir: Path) -> None:
"""拷贝丝路实体贴图为标准文件名(保留 Unity 原名,额外写规范别名)。"""
skin_root = theme_dir / "skin"
player_skin = theme_dir / "player" / "skin"
if player_skin.is_dir():
copy_skin_tree(player_skin, skin_root)
skin_front = skin_root / "待机正面"
skin_back = skin_root / "待机背面"
skin_front.mkdir(parents=True, exist_ok=True)
skin_back.mkdir(parents=True, exist_ok=True)
ensure_directory_meta(skin_root.parent / f"{skin_root.name}.meta")
ensure_directory_meta(skin_front.parent / f"{skin_front.name}.meta")
ensure_directory_meta(skin_back.parent / f"{skin_back.name}.meta")
front_src = first_existing_file(
skin_front / "1.png",
player_skin / "待机正面" / "1.png",
theme_dir / "player" / "idel-正" / "1.png",
theme_dir / "player_F.png",
)
if front_src and front_src != skin_front / "1.png":
shutil.copy2(front_src, skin_front / "1.png")
if (skin_front / "1.png").is_file():
ensure_png_meta(skin_front / "1.png")
back_src = first_existing_file(
skin_back / "1.png",
player_skin / "待机背面" / "1.png",
theme_dir / "player" / "idel-反" / "1.png",
theme_dir / "player_B.png",
)
if back_src and back_src != skin_back / "1.png":
shutil.copy2(back_src, skin_back / "1.png")
if (skin_back / "1.png").is_file():
ensure_png_meta(skin_back / "1.png")
for src_name, dst_name in SILU_CANONICAL_COPIES:
src = theme_dir / src_name
dst = theme_dir / dst_name
if src.is_file():
shutil.copy2(src, dst)
ensure_png_meta(dst)
def normalize_nprop_filenames(theme_dir: Path) -> None:
"""Unity numMan 等主题 nProp 文件名可能带尾空格,统一为无空格文件名。"""
for png in theme_dir.rglob("nProp*.png"):
stem = png.stem
if stem != stem.rstrip():
fixed = png.with_name(f"{stem.rstrip()}.png")
if fixed != png:
if fixed.is_file():
png.unlink()
else:
png.rename(fixed)
meta = png.with_suffix(".png.meta")
fixed_meta = fixed.with_suffix(".png.meta")
if meta.is_file() and not fixed_meta.is_file():
meta.rename(fixed_meta)
# HUD 按钮UIMain 右侧,与 themes-database.json hud 字段对应)
HUD_GLOBS = (
"anniu_*.png",
"redarmy*.png",
"素材切图-*.png",
"素材切图2-*.png",
"1倍速.png",
"2倍速.png",
"4倍速.png",
"声音.png",
"声音关闭.png",
"导航图标.png",
"重置.png",
"放大图标.png",
"缩小图标.png",
)
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
if not src.is_dir():
return 0
dst = out_root / style_key
dst.mkdir(parents=True, exist_ok=True)
count = 0
seen: set[str] = set()
for pattern in HUD_GLOBS:
for png in src.glob(pattern):
if not png.is_file():
continue
key = str(png.resolve())
if key in seen:
continue
seen.add(key)
target = dst / png.name
shutil.copy2(png, target)
count += 1
return count
def copy_theme(unity_tex: Path, out_root: Path, style_key: str, rel: str) -> int:
src = unity_tex if rel == "." else unity_tex / rel
dst = out_root / style_key
dst.mkdir(parents=True, exist_ok=True)
count = 0
if rel == ".":
for png in src.glob("*.png"):
shutil.copy2(png, dst / png.name)
if png.name == "Ship_F.png":
shutil.copy2(png, dst / "ship_F.png")
count += 1
return count
if not src.is_dir():
print(f" skip missing theme dir: {src}")
return 0
for png in src.rglob("*.png"):
rel_path = png.relative_to(src)
# 跳过 HUD 专用碎图(由 copy_hud_assets 扁平拷贝到主题根目录)
if png.name.startswith("anniu_") or "倍速" in png.name or png.name.startswith("声音"):
continue
if png.name.startswith("素材切图") or png.name.startswith("redarmy"):
continue
target = dst / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(png, target)
count += 1
# silu 装饰砖块别名(烘焙脚本用 Decor23
decor = dst / "素材切图-23.png"
if decor.is_file():
shutil.copy2(decor, dst / "Decor23.png")
normalize_nprop_filenames(dst)
if style_key == "silu":
normalize_silu_assets(dst)
return count
def copy_prop(unity_tex: Path, out_root: Path) -> int:
src = unity_tex / "Prop"
if not src.is_dir():
return 0
dst = out_root / "prop"
dst.mkdir(parents=True, exist_ok=True)
n = 0
for png in src.glob("*.png"):
name = png.name.replace("载具 正面", "vehicle_F").replace("载具 背面", "vehicle_B")
shutil.copy2(png, dst / name)
n += 1
return n
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--unity-root", required=True)
ap.add_argument("--out", default="assets/resources/textures")
ap.add_argument("--themes", default=",".join(THEME_MAP.keys()))
args = ap.parse_args()
project = Path(__file__).resolve().parent.parent
unity_tex = Path(args.unity_root) / "Assets" / "Texture"
out_root = project / args.out
if not unity_tex.is_dir():
raise SystemExit(f"Unity Texture 不存在: {unity_tex}")
themes = [t.strip() for t in args.themes.split(",") if t.strip()]
total = 0
for key in themes:
rel = THEME_MAP.get(key)
if rel is None:
print(f"unknown theme: {key}")
continue
n = copy_theme(unity_tex, out_root, key, rel)
hn = copy_hud_assets(unity_tex, out_root, key, rel)
print(f" {key}: {n} png (+ {hn} hud)")
total += n + hn
pn = copy_prop(unity_tex, out_root)
print(f" prop: {pn} png")
total += pn
print(f"Imported {total} files -> {out_root}")
print("请在 Cocos Creator 中刷新 assets/resources/textures然后运行:")
print(" python3 tools/fix_tile_texture_metas.py")
if __name__ == "__main__":
main()

105
tools/level-map-editor.md Normal file
View File

@@ -0,0 +1,105 @@
# 关卡地图编辑(对齐 Unity Tile Palette
## 布局(与 Unity 一致)
| 区域 | Unity | Cocos |
|------|-------|-------|
| 中间 | `Level2` 预制体Ground + Border Tilemap | 网格画布 = 当前关卡布局;烘焙后见 `level-prefabs/LevelN.prefab` |
| 右侧 | Tile PaletteBaseblock、JumpBlock、墙块… | **地图模块** `map-tiles/MapTile_*.prefab` |
| 数据 | `Levels*.cs` + Addressables | `levels-database.json` |
## 1. 启用扩展
1. **扩展管理器** → 启用 `level-map-editor`
2. 菜单 **扩展 → 关卡地图编辑** 打开面板
## 2. 场景内格子吸附
### 自动吸附(推荐)
1. 打开 `level-prefabs/LevelN.prefab`
2. 选中 **LevelN 根节点** → 添加组件 **GridSnapHelper**
3. 在场景中拖动 Ground/Border 下瓦片时,会自动吸附到等距格子中心,并可显示蓝色参考网格
或在关卡地图编辑面板点击 **启用场景吸附助手**(会为当前 Level 根节点添加 `GridSnapHelper`)。
### 手动吸附
1. 在场景编辑器选中一个或多个瓦片节点
2. 面板点击 **场景选中吸附**,立即对齐到最近格子
### 面板画布吸附
鼠标在编辑画布上移动时,高亮当前吸附格子并显示坐标 `(x, y)`,点击/拖拽绘制时自动落在格子中心。
## 3. 绘制工具(对齐 Unity Tile Palette
| 工具 | 快捷键 | 操作 |
|------|--------|------|
| **画笔** | `B` | 右侧选瓦片后,点击/拖拽在网格上绘制 |
| **框选** | `R` | 拖拽矩形,松开后用当前瓦片填满区域 |
| **吸管** | `I` | 点击已有格子,吸取瓦片并切换为画笔 |
| **橡皮擦** | `E` | 点击/拖拽删除当前层Ground/Border上的格子 |
| **填充** | `F` | 油漆桶:填充相连的同类型格子(需先选瓦片) |
1. 选择 **Ground** / **Border** 绘制层
2. 工具栏 **调色板** 下拉选择主题
3. 右侧点选瓦片(画笔/框选/填充需要;吸管/橡皮擦不需要)
4. 在中间等距网格上操作;鼠标移动会高亮吸附格并显示坐标
5. **保存 JSON****烘焙预制体****打开 Level 预制体**
生成主题调色板数据:
```bash
python3 tools/build_theme_palettes.py --unity-root "/path/to/主站"
```
## 3. 地图模块预制体
路径:`assets/resources/map-tiles/`
| 预制体 | 用途 |
|--------|------|
| `MapTile_Baseblock` | 可走地面 |
| `MapTile_JumpBlock` | 跳跃格 |
| `MapTile_WallBlock` | 阻挡墙 |
| `MapTile_Decor23` | 装饰墙Unity 素材切图-23 |
重新生成模块与调色板列表:
```bash
python3 tools/bake_map_tile_prefabs.py
```
首次使用前在 Creator 中 **刷新资源**,确保 `JumpBlock.png``WallBlock.png``Decor23.png` 已导入。
## 4. 在编辑器里看到砖块(与 Unity 一致)
烘焙后的 `LevelN.prefab` 每个格子带 **Sprite + UITransform**(丝路贴图)。
打开预制体后若场景是空的:
1. 场景编辑器左上角切到 **2D**
2.**F** 聚焦到选中节点,或缩放到原点附近(格子约在 x,y ∈ [-200, 200]
3. 展开 **Ground / Border**,应能看到等距砖块贴图
与 Unity 差异Unity 用 **Tilemap** 绘制Cocos 用 **子节点 + Sprite**(逻辑仍读 `levels-database.json`)。
## 5. 在场景中手动摆放(可选)
与 Unity 在 Scene 里拖 Tile 类似:
1. 在资源管理器打开 `level-prefabs/Level2.prefab`
2.`map-tiles``MapTile_*` **拖到** `Ground``Border` 节点下
3. 设置节点位置为格子坐标 × **56**`CELL_PIXEL`
4. 保存后仍需同步 `levels-database.json`(用面板编辑或重新导出)
推荐以 **面板编辑 + 烘焙** 为主,保证 JSON 与预制体一致。
## 5. 从 Unity 重新同步
```bash
python3 tools/export_all_levels.py --unity-root "/path/to/主站" --output assets/resources/level/levels-database.json
python3 tools/bake_cocos_level_prefabs.py --db assets/resources/level/levels-database.json --out-dir assets/resources/level-prefabs
python3 tools/bake_map_tile_prefabs.py
```

File diff suppressed because it is too large Load Diff

98
tools/level_id.py Normal file
View File

@@ -0,0 +1,98 @@
"""关卡 ID 约定:与 Unity 首关 91601、主站 config.js BEGINNING_REAL_LVID 一致。"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
LEVEL_ID_BASE = 91601 # Unity 首关 / 主站 BEGINNING_REAL_LVID
PREFAB_DIR = "level-prefabs"
LEVEL_DB_REL = "assets/level-data/levels-database.json"
def level_db_path(project: Path) -> Path:
return project / LEVEL_DB_REL
def is_game_level_id(level_id: int) -> bool:
return level_id >= LEVEL_ID_BASE
def normalize_level_id(raw_id: int) -> int:
"""Unity Levels*.cs 中的 ID 原样使用,不做 Cocos 侧二次编号。"""
return raw_id
def internal_level_index(level_id: int) -> int:
if is_game_level_id(level_id):
return level_id - LEVEL_ID_BASE
return level_id
def is_external_level_id(level_id: int) -> bool:
return is_game_level_id(level_id)
def prefab_resource_path(level_id: int) -> str:
return f"{PREFAB_DIR}/Level{level_id}"
def normalize_db(data: dict) -> dict:
data.setdefault("levels", {})
data["levelIdBase"] = LEVEL_ID_BASE
return data
def next_available_level_id(data: dict) -> int:
levels = data.get("levels") or {}
ids: list[int] = []
for key in levels:
try:
ids.append(int(key))
except ValueError:
continue
if not ids:
return LEVEL_ID_BASE
return max(ids) + 1
def sync_level_entry(cfg: dict, level_id: int) -> dict:
out = dict(cfg)
out["levelID"] = level_id
out["cocosPrefab"] = prefab_resource_path(level_id)
unity = out.get("unityPrefab")
if isinstance(unity, str) and unity:
import re
out["unityPrefab"] = re.sub(
r"Level\d+\.prefab$",
f"Level{level_id}.prefab",
unity.replace("\\", "/"),
flags=re.I,
)
return out
def update_db_stats(data: dict) -> None:
levels = data.get("levels") or {}
total = len(levels)
data["stats"] = {
**(data.get("stats") or {}),
"total": total,
"withPrefabTilemap": total,
}
def touch_database(db_path: Path, level_ids: list[int] | None = None) -> None:
"""保存/烘焙后刷新数据库元数据与关卡条目。"""
if not db_path.is_file():
return
data = normalize_db(json.loads(db_path.read_text(encoding="utf-8")))
ids = level_ids if level_ids is not None else [int(k) for k in data["levels"]]
for lid in ids:
key = str(lid)
if key not in data["levels"]:
continue
data["levels"][key] = sync_level_entry(data["levels"][key], lid)
update_db_stats(data)
data["generatedAt"] = datetime.now(timezone.utc).isoformat()
db_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

79
tools/levels-database.md Normal file
View File

@@ -0,0 +1,79 @@
# 关卡数据库 `levels-database.json`
## 单一数据源
| 文件 | 说明 |
|------|------|
| `assets/resources/level/levels-database.json` | **全部关卡**(增删查改改这个文件) |
| `assets/scripts/level/LevelDatabase.ts` | 加载与 CRUD API |
| `assets/scripts/level/LevelRegistry.ts` | 游戏内统一入口 |
每条关卡结构与 Unity 一致:
- `spawns``Levels*.cs`(运行时由 `GameManager` 生成 Player/Prop
- `ground` / `border` ← Unity `LevelN.prefab` Tilemap烘焙进 Cocos 预制体)
- `unityPrefab` ← 原 Unity 资源路径(便于对照)
## 关卡地图编辑面板(对齐 Unity Tile Palette
**`tools/level-map-editor.md`**:中间编辑关卡网格,右侧选择 `map-tiles/MapTile_*` 模块绘制,保存后烘焙预制体。
## 关卡地图预制体(与 Unity Instantiate 一致)
| 路径 | 说明 |
|------|------|
| `assets/resources/level-prefabs/Level{N}.prefab` | 地图视觉 + `LevelMapData`Ground/Border 子节点) |
| `assets/scripts/level/LevelMapData.ts` | 挂在预制体根节点,提供可走/阻挡逻辑 |
| `tools/bake_cocos_level_prefabs.py` | 从 `levels-database.json` 批量生成预制体 |
运行时 `GameManager` 只做:
1. `resources.load('level-prefabs/LevelN')``instantiate`
2. 从预制体 `LevelMapData` 读取 `ground` / `border`
3.`spawns` 生成角色与道具
**不再**用代码 `drawGridDebug` 拼地图。
修改 JSON 后需重新烘焙:
```bash
python3 tools/bake_cocos_level_prefabs.py \
--db assets/resources/level/levels-database.json \
--out-dir assets/resources/level-prefabs
```
## 贴图(角色 / 砖块)
从 Unity `Assets/Texture` 导入:
```bash
python3 tools/import_unity_textures.py --unity-root "/path/to/主站"
# Creator 刷新 textures 后:
python3 tools/fix_tile_texture_metas.py
```
目录与主题:`textures/default``silu``chinese``redArmy``numMan``snow``sanxing`
## 从 Unity 重新导出
```bash
cd /path/to/cocos/tfrh001
python3 tools/export_all_levels.py \
--unity-root "/path/to/主站" \
--output assets/resources/level/levels-database.json
```
调试可加 `--limit 50` 只导出前 50 关。
## 增删查改示例(代码)
```typescript
import { getLevelConfig, setLevel, removeLevel, addLevel, getLevelIds } from './level/LevelRegistry';
const cfg = getLevelConfig(3);
setLevel({ ...cfg!, levelID: 99, spawns: [...] });
removeLevel(99);
console.log(getLevelIds());
```
直接编辑 JSON 后,在 Cocos 中刷新资源并重新预览即可。

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# 将关卡预制体从 resources 拆到独立 Asset Bundlelevel-prefabs减小 resources 包体积。
#
# 用法:
# bash tools/migrate-level-prefab-bundle.sh # 执行迁移
# bash tools/migrate-level-prefab-bundle.sh --dry-run
#
# 迁移后请在 Cocos Creator 中重新构建 Web再运行 package-for-project.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SRC="$PROJECT_DIR/assets/resources/level-prefabs"
DST="$PROJECT_DIR/assets/bundle-level-prefabs/level-prefabs"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
-h|--help)
echo "将 assets/resources/level-prefabs 移至 assets/bundle-level-prefabs/level-prefabs"
exit 0
;;
*) echo "未知参数: $1" >&2; exit 1 ;;
esac
done
if [[ ! -d "$SRC" ]]; then
echo "源目录不存在(可能已迁移): $SRC" >&2
exit 0
fi
PREFAB_COUNT="$(find "$SRC" -maxdepth 1 -name 'Level*.prefab' 2>/dev/null | wc -l | tr -d ' ')"
echo "==> 待迁移预制体: ${PREFAB_COUNT}"
echo " $SRC"
echo " -> $DST"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "dry-run未修改文件"
exit 0
fi
mkdir -p "$PROJECT_DIR/assets/bundle-level-prefabs"
mkdir -p "$(dirname "$DST")"
if [[ -d "$DST" ]]; then
echo "错误: 目标已存在: $DST" >&2
echo "若需重新迁移,请先手动删除 bundle-level-prefabs/level-prefabs" >&2
exit 1
fi
mv "$SRC" "$DST"
rm -f "$PROJECT_DIR/assets/resources/level-prefabs.meta"
# 子目录 metaCreator 会在下次打开时补全 uuid此处提供基础结构
cat > "$DST.meta" <<'META'
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"files": [],
"subMetas": {},
"userData": {}
}
META
echo "==> 迁移完成。下一步:"
echo " 1. 打开 Cocos Creator确认 bundle-level-prefabs 显示为 Asset Bundle「level-prefabs」"
echo " 2. 构建 Web Desktop"
echo " 3. bash tools/package-for-project.sh"

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env node
/**
* @deprecated 请用 write-deploy-manifest.js不再复制 cdn-upload 目录)
* 兼容旧脚本名,仅生成 build/deploy/ 清单。
*/
require('./write-deploy-manifest.js');

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# 生成 OSS 上传清单(运行时包 build/mstest5 与本地 static/unity 相同,不再复制 cdn-upload
#
# bash tools/package-cdn-upload.sh
# bash tools/package-cdn-upload.sh --cdn-base https://oss.example.com/game
# bash tools/package-cdn-upload.sh --zip
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/build/web-desktop"
PACK_DIR="$PROJECT_DIR/build/mstest5"
DEPLOY_DIR="$PROJECT_DIR/build/deploy"
UNITY_REF="${UNITY_REF:-$HOME/tfrh/竞赛/mstest5}"
CDN_BASE=""
MAKE_ZIP=0
SKIP_BUILD=0
SKIP_PACK=0
usage() {
cat <<'EOF'
用法: package-cdn-upload.sh [options]
1. package-for-cdn → build/mstest5/(运行时包)
2. write-deploy-manifest → build/deploy/(清单,不复制包)
运行时包 = 本地 static/unity = OSS unitycdndir仅 URL 不同)
选项:
--build DIR Cocos 构建目录
--pack DIR 运行时包目录(默认 build/mstest5
--cdn-base URL OSS 根地址
--zip 生成 build/mstest5-runtime.zip
--skip-build 跳过构建检查
--skip-pack 跳过打包,仅生成清单
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--build) BUILD_DIR="$2"; shift 2 ;;
--pack) PACK_DIR="$2"; shift 2 ;;
--cdn-base) CDN_BASE="$2"; shift 2 ;;
--zip) MAKE_ZIP=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
--skip-pack) SKIP_PACK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage; exit 1 ;;
esac
done
if [[ "$SKIP_BUILD" -eq 0 ]]; then
bash "$SCRIPT_DIR/verify-split-build.sh" "$BUILD_DIR"
fi
if [[ "$SKIP_PACK" -eq 0 ]]; then
bash "$SCRIPT_DIR/package-for-project.sh" --build "$BUILD_DIR" --out "$PACK_DIR" \
${CDN_BASE:+--cdn-base "$CDN_BASE"} \
${MAKE_ZIP:+--zip}
else
MANIFEST_ARGS=("$PACK_DIR" --manifest-dir "$DEPLOY_DIR")
[[ -n "$CDN_BASE" ]] && MANIFEST_ARGS+=(--cdn-base "$CDN_BASE")
[[ "$MAKE_ZIP" -eq 1 ]] && MANIFEST_ARGS+=(--zip)
node "$SCRIPT_DIR/write-deploy-manifest.js" "${MANIFEST_ARGS[@]}"
fi
echo ""
echo "完成。"
echo " 运行时包(本地=CDN: $PACK_DIR"
echo " 上传清单: $DEPLOY_DIR/UPLOAD-MANIFEST.txt"

377
tools/package-for-cdn.js Normal file
View File

@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* 将 Cocos Web 构建产物整理为与 Unity 参考包完全一致的目录/文件名:
* /Users/liuyufei/tfrh/竞赛/mstest5
*
* node tools/package-for-cdn.js <cocosBuildDir> <outDir>
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const { execSync } = require('child_process');
const {
patchPreloadSettings,
printPackageReport,
minifyLevelsDatabase,
brotliCompressFile,
formatBytes,
} = require('./package-optimize');
const { listRuntimeFiles, assertRuntimePack } = require('./runtime-pack');
const { splitLevelBundles } = require('./split-level-bundles');
const buildDir = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3]);
const unityRef = path.resolve(process.argv[4] || '/Users/liuyufei/tfrh/竞赛/mstest5');
const PRODUCT = 'mstest5';
function parseCatalogBundles(catalogPath) {
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
const names = [...new Set(
(catalog.m_InternalIds || [])
.filter((id) => String(id).includes('.bundle'))
.map((id) => String(id).split('/').pop()),
)];
const scenesAll = names.find((n) => n.includes('scenes_all'));
const assetsAll = names.find((n) => n.includes('assets_all'));
const levelsAll = names.find((n) => n.includes('levels_all'));
const shaders = names.find((n) => n.includes('unitybuiltinshaders'));
if (!scenesAll || !assetsAll) {
throw new Error(`catalog.json missing scenes/assets bundle: ${names.join(', ')}`);
}
return { shaders, scenesAll, assetsAll, levelsAll, all: names };
}
/** 复制 catalog 后剥离历史 levels_all 条目,再写入本次 bundle */
function patchCatalogAddLevelsBundle(catalogPath, bundleFileName) {
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
const entry = `{UnityEngine.AddressableAssets.Addressables.RuntimePath}/WebGL/${bundleFileName}`;
const ids = (catalog.m_InternalIds || []).filter((id) => !String(id).includes('levels_all'));
ids.push(entry);
catalog.m_InternalIds = ids;
fs.writeFileSync(catalogPath, JSON.stringify(catalog), 'utf8');
}
/** 从 catalog 移除 levels_all 条目(仅 MERGE_LEVELS=1 合并包时使用) */
function patchCatalogRemoveLevelsBundle(catalogPath) {
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
catalog.m_InternalIds = (catalog.m_InternalIds || []).filter((id) => !String(id).includes('levels_all'));
fs.writeFileSync(catalogPath, JSON.stringify(catalog), 'utf8');
}
function copyAssetsDirExcept(srcAssetsDir, dstAssetsDir, skipNames) {
const skip = new Set(skipNames || []);
if (!fs.existsSync(srcAssetsDir)) return;
mkdirp(dstAssetsDir);
for (const ent of fs.readdirSync(srcAssetsDir, { withFileTypes: true })) {
if (skip.has(ent.name)) continue;
const sp = path.join(srcAssetsDir, ent.name);
const dp = path.join(dstAssetsDir, ent.name);
if (ent.isDirectory()) copyDir(sp, dp);
else copyFile(sp, dp);
}
}
function hashFileMd5(filePath) {
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
}
function attachLevelsDatabase(outDir) {
const levelsDbSrc = path.join(__dirname, '../assets/level-data/levels-database.json');
if (!fs.existsSync(levelsDbSrc)) {
console.warn('>>> 警告: 未找到 levels-database.json');
return;
}
const levelsDbDst = path.join(outDir, 'levels-database.json');
const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst);
console.log(`>>> levels-database.json 压缩: ${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)}`);
}
if (!buildDir || !outDir) {
console.error('Usage: package-for-cdn.js <cocosBuildDir> <outDir> [unityRefDir]');
process.exit(1);
}
function rmrf(p) {
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
function copyFile(s, d) { mkdirp(path.dirname(d)); fs.copyFileSync(s, d); }
function copyDir(s, d) {
if (!fs.existsSync(s)) return;
mkdirp(d);
for (const ent of fs.readdirSync(s, { withFileTypes: true })) {
const sp = path.join(s, ent.name);
const dp = path.join(d, ent.name);
if (ent.isDirectory()) copyDir(sp, dp);
else copyFile(sp, dp);
}
}
function zipDir(srcDir, outFile) {
const absOut = path.resolve(outFile);
mkdirp(path.dirname(absOut));
if (fs.existsSync(absOut)) fs.unlinkSync(absOut);
execSync(`cd "${path.resolve(srcDir)}" && zip -0 -q -r "${absOut}" .`, { stdio: 'pipe' });
}
function patchText(file, fn) {
const t = fs.readFileSync(file, 'utf8');
fs.writeFileSync(file, fn(t), 'utf8');
}
function brotliStub(dst, bytes) {
const buf = zlib.brotliCompressSync(bytes || Buffer.from([0]));
fs.writeFileSync(dst, buf);
}
/**
* 分包模式下:须先 loadLevelPrefab 再 VisualAssets.preload否则 resources 占满后 level-prefabs 加载失败。
* 对 Cocos 构建产物 assets/main/index.js 做稳定字符串 patch无需立即重编工程
*/
function patchMainIndexForSplitLoad(mainIndexPath) {
if (!fs.existsSync(mainIndexPath)) {
console.warn('>>> 跳过 main/index 分包 patch无 assets/main/index.js');
return;
}
let s = fs.readFileSync(mainIndexPath, 'utf8');
const rules = [
{
desc: 'AppBootstrap: 首关前不 preload UI 贴图',
old: 'yield h.preload(),yield O.preload()',
neu: 'yield O.preload()',
},
{
desc: 'AppBootstrap: 进关再 loadLevel不在 bootstrap 拉首关)',
old: 'yield F.createNewLevel(F.initialLevelID),S.purgeScene(c),F.scheduleOnce((function(){return S.purgeScene(c)}),0),F.scheduleOnce((function(){return S.purgeScene(c)}),.15),F.markReady()',
neu: 'F.markReady()',
},
{
desc: 'GameController: 使用 loader 全局 loadLevelPrefab hook',
old: 'var l,r=yield z(i);yield H.preload(this.uiStyle)',
neu: 'var l,r=yield(globalThis.__tfrhLoadLevelPrefab||z)(i);yield H.preload(this.uiStyle)',
},
{
desc: 'LevelPrefabLoader: 去掉裸 LevelN 路径',
old: 'return[].concat(new Set([r,n,"level-prefabs/"+n]))',
neu: 'return[].concat(new Set([r,"level-prefabs/"+n]))',
},
{
desc: 'LevelPrefabLoader: 进关前 ensureLevelPack',
old: 'function*(e){yield a();var r=v(e);',
neu: 'function*(e){yield a();var _h=globalThis.__tfrhEnsureLevelPack,_m=/Level(\\d+)\\s*$/.exec(e.trim()),_lid=_m?parseInt(_m[1],10):void 0;if(_lid!==void 0&&typeof _h==="function")yield _h(_lid);var r=v(e);',
},
{
desc: 'LevelPrefabLoader: ensureLevelPack 后清空 bundle 缓存 (新构建 w/L/s/f)',
old: 'yield u(),yield L(e);var r=v(e),n=null;try{var l=yield c();return yield d((function(e){return p(l,e)}),r)}catch(e){n=e,console.warn("[LevelPrefabLoader] level-prefabs bundle 加载失败",e)}var t=yield y(e);if(null!=t&&t.isValid)return t;throw console.error(\'[LevelPrefabLoader] 关卡预制体未找到。请确认 bundle-level-prefabs 已标记为 Asset Bundle "level-prefabs",并运行: python3 tools/bake_cocos_level_prefabs.py\',n),n instanceof Error?n:new Error("missing prefab: "+e)',
neu: 'yield u(),yield L(e);s=null,f=null;var r=v(e);try{var l=yield c();return yield d((function(e){return p(l,e)}),r)}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab: "+e)}',
},
{
desc: 'LevelPrefabLoader: ensureLevelPack 后清空 bundle 缓存 (旧构建 g/w/c/f)',
old: 'yield a(),yield w(e);var r=b(e);try{var n=yield p();return yield y((function(e){return d(n,e)}),r)}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab")}',
neu: 'yield a(),yield w(e);c=null,f=null;var r=b(e);try{var n=yield p();return yield y((function(e){return d(n,e)}),r)}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab")}',
},
{
desc: 'LevelPrefabLoader: 禁用 resources 回退',
old: '}catch(e){console.warn("[LevelPrefabLoader] level-prefabs bundle 不可用,尝试 resources",e)}var l=yield L(e);if(null!=l&&l.isValid)return l;try{return yield y(v,r)}catch(e){throw console.error(\'[LevelPrefabLoader] 关卡预制体未找到。请确认 bundle-level-prefabs 已标记为 Asset Bundle "level-prefabs",并运行: python3 tools/bake_cocos_level_prefabs.py\',e),e}',
neu: '}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab")}',
},
];
let n = 0;
for (const { desc, old, neu } of rules) {
if (!s.includes(old)) continue;
s = s.replace(old, neu);
n += 1;
console.log(`>>> main/index.js patch: ${desc}`);
}
if (n === 0) {
console.warn('>>> 警告: main/index.js 未匹配分包 patch请确认 Cocos 已构建或更新 patch 规则)');
}
fs.writeFileSync(mainIndexPath, s, 'utf8');
}
if (!fs.existsSync(path.join(buildDir, 'index.html'))) {
console.error('Missing Cocos build:', buildDir);
process.exit(1);
}
if (!fs.existsSync(path.join(unityRef, 'index.html'))) {
console.error('Missing Unity reference index.html:', unityRef);
console.error('需要完整 Unity 参考包(如 ~/tfrh/竞赛/mstest5static/unity 扁平包不可用。');
process.exit(1);
}
if (!fs.existsSync(path.join(unityRef, 'StreamingAssets/aa/catalog.json'))) {
console.error('Missing Unity reference catalog:', unityRef);
process.exit(1);
}
const tmp = path.join(outDir, '..', '.pack-tmp-' + Date.now());
rmrf(outDir);
mkdirp(tmp);
console.log('>>> Unity 参考:', unityRef);
console.log('>>> Cocos 构建:', buildDir);
console.log('>>> 输出(运行时包):', outDir);
// —— 1. StreamingAssets 元数据(不含 index.html / TemplateData与 CDN 上传一致)——
mkdirp(outDir);
const aaDir = path.join(outDir, 'StreamingAssets', 'aa');
const webglDir = path.join(aaDir, 'WebGL');
mkdirp(webglDir);
copyFile(path.join(unityRef, 'StreamingAssets/aa/settings.json'), path.join(aaDir, 'settings.json'));
copyFile(path.join(unityRef, 'StreamingAssets/aa/catalog.json'), path.join(aaDir, 'catalog.json'));
copyDir(path.join(unityRef, 'StreamingAssets/aa/AddressablesLink'), path.join(aaDir, 'AddressablesLink'));
const BUNDLE = parseCatalogBundles(path.join(aaDir, 'catalog.json'));
console.log('>>> catalog bundles:', BUNDLE.all.join(', '));
if (BUNDLE.shaders) {
copyFile(
path.join(unityRef, 'StreamingAssets/aa/WebGL', BUNDLE.shaders),
path.join(webglDir, BUNDLE.shaders),
);
}
// —— 2. 打 scenes bundleCocos 运行时 JS——
const scenesStage = path.join(tmp, 'scenes');
mkdirp(scenesStage);
for (const f of ['index.js', 'application.js']) {
copyFile(path.join(buildDir, f), path.join(scenesStage, f));
}
copyDir(path.join(buildDir, 'src'), path.join(scenesStage, 'src'));
copyDir(path.join(buildDir, 'cocos-js'), path.join(scenesStage, 'cocos-js'));
copyFile(
path.join(path.dirname(__filename), '../web-template/cocos-bridge.js'),
path.join(scenesStage, 'cocos-bridge.js'),
);
patchText(path.join(scenesStage, 'application.js'), (t) =>
t.replace(/this\.settingsPath\s*=\s*'[^']*'/, "this.settingsPath = 'src/settings.json'"),
);
// 保持 assets.server 为空,由 loader fetch shim / 本地解压目录提供资源
patchText(path.join(scenesStage, 'src', 'settings.json'), (t) => {
const j = JSON.parse(t);
j.assets = j.assets || {};
j.assets.server = '';
patchPreloadSettings(j, { preloadResources: false, preloadLevelPrefabs: false });
return JSON.stringify(j);
});
zipDir(scenesStage, path.join(webglDir, BUNDLE.scenesAll));
// —— 3a. assets_all核心资源不含 level-prefabs——
// 默认分包MERGE_LEVELS=1 时合并 level-prefabs 进 assets_all不推荐
const MERGE_LEVELS = process.env.MERGE_LEVELS === '1';
const assetsCoreStage = path.join(tmp, 'assets-core');
mkdirp(assetsCoreStage);
if (MERGE_LEVELS) {
copyDir(path.join(buildDir, 'assets'), path.join(assetsCoreStage, 'assets'));
patchCatalogRemoveLevelsBundle(path.join(aaDir, 'catalog.json'));
console.log('>>> MERGE_LEVELS=1: level-prefabs 已并入 assets_all');
} else {
copyAssetsDirExcept(
path.join(buildDir, 'assets'),
path.join(assetsCoreStage, 'assets'),
['level-prefabs'],
);
// level-prefabs 壳config + index进首屏各关 import 单独按需包
const lpShellSrc = path.join(buildDir, 'assets', 'level-prefabs');
const lpShellDst = path.join(assetsCoreStage, 'assets', 'level-prefabs');
mkdirp(lpShellDst);
for (const f of ['config.json', 'index.js']) {
const sp = path.join(lpShellSrc, f);
if (fs.existsSync(sp)) copyFile(sp, path.join(lpShellDst, f));
}
patchMainIndexForSplitLoad(path.join(assetsCoreStage, 'assets', 'main', 'index.js'));
console.log('>>> 分包模式: assets_all 含 level-prefabs 壳,不含关卡 import');
}
const nativeWasm = path.join(buildDir, 'cocos-js', 'assets');
if (fs.existsSync(nativeWasm)) {
copyDir(nativeWasm, path.join(assetsCoreStage, 'assets'));
}
copyFile(path.join(scenesStage, 'src', 'settings.json'), path.join(assetsCoreStage, 'src', 'settings.json'));
if (fs.existsSync(path.join(scenesStage, 'src', 'effect.bin'))) {
copyFile(path.join(scenesStage, 'src', 'effect.bin'), path.join(assetsCoreStage, 'src', 'effect.bin'));
}
const assetsCoreZip = path.join(webglDir, BUNDLE.assetsAll);
zipDir(assetsCoreStage, assetsCoreZip);
console.log(`>>> assets_core (assets_all): ${formatBytes(fs.statSync(assetsCoreZip).size)}`);
// —— 3b. 每关独立 bundle + levels-manifest.json ——
const levelPrefabsSrc = path.join(buildDir, 'assets', 'level-prefabs');
let levelPackStats = null;
if (!MERGE_LEVELS) {
if (!fs.existsSync(levelPrefabsSrc)) {
console.error('>>> 错误: 分包模式需要 build/assets/level-prefabs');
console.error('>>> 请确认 bundle-level-prefabs 已配置并在 Cocos 中重新构建 Web Desktop');
process.exit(1);
}
patchCatalogRemoveLevelsBundle(path.join(aaDir, 'catalog.json'));
const manifestPath = path.join(aaDir, 'levels-manifest.json');
levelPackStats = splitLevelBundles(levelPrefabsSrc, webglDir, manifestPath, tmp);
}
attachLevelsDatabase(outDir);
// —— 4. Build/ 仅 4 个文件(与 Unity 同名)——
const buildOut = path.join(outDir, 'Build');
mkdirp(buildOut);
const loaderTpl = fs.readFileSync(
path.join(path.dirname(__filename), '../web-template/mstest5-cocos-loader.js'),
'utf8',
);
fs.writeFileSync(
path.join(buildOut, `${PRODUCT}.loader.js`),
loaderTpl.replace(/__PRODUCT_NAME__/g, PRODUCT),
'utf8',
);
// .br 占位index.html config 需要文件存在;实际由自定义 loader 启动 Cocos
brotliStub(path.join(buildOut, `${PRODUCT}.data.br`));
brotliStub(path.join(buildOut, `${PRODUCT}.framework.js.br`));
brotliStub(path.join(buildOut, `${PRODUCT}.wasm.br`));
rmrf(tmp);
// —— 5. 独立调试页(可选,不进 static/unity 也不上传 OSS——
const standaloneDir = path.join(path.dirname(outDir), 'standalone-player');
rmrf(standaloneDir);
mkdirp(standaloneDir);
copyFile(path.join(unityRef, 'index.html'), path.join(standaloneDir, 'index.html'));
copyDir(path.join(unityRef, 'TemplateData'), path.join(standaloneDir, 'TemplateData'));
for (const item of ['Build', 'StreamingAssets', ...['levels-database.json', 'levels-database.json.br']]) {
const src = path.join(outDir, item);
const dst = path.join(standaloneDir, item);
if (!fs.existsSync(src)) continue;
if (fs.statSync(src).isDirectory()) copyDir(src, dst);
else copyFile(src, dst);
}
console.log('>>> 独立调试页:', standaloneDir);
assertRuntimePack(outDir, { requireLevelsBundle: false, requireLevelsManifest: !MERGE_LEVELS });
const runtimeFiles = listRuntimeFiles(outDir);
console.log('\n>>> 运行时包文件(本地 static/unity = OSS unitycdndir:');
for (const { rel } of runtimeFiles) console.log(' ', rel);
const requiredBundles = [
path.join(webglDir, BUNDLE.scenesAll),
path.join(webglDir, BUNDLE.assetsAll),
];
if (BUNDLE.shaders) requiredBundles.push(path.join(webglDir, BUNDLE.shaders));
for (const p of requiredBundles) {
if (!fs.existsSync(p) || fs.statSync(p).size < 100) {
console.error('>>> bundle 无效:', p);
process.exit(1);
}
}
const preloadNote = MERGE_LEVELS
? 'scenes ∥ assets_all 合并包 (MERGE_LEVELS=1)'
: `scenes ∥ assets_core 首屏;关卡包进关按需 (${levelPackStats ? levelPackStats.packed : '?'} 关)`;
console.log('\n完成。运行时包 → 本地 import-to-unity.sh / OSS unitycdndir同一目录');
printPackageReport(outDir, { preloadNote });

56
tools/package-for-cdn.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
# Cocos → Unity mstest5 完全一致目录结构(参考 /Users/liuyufei/tfrh/竞赛/mstest5
#
# bash tools/package-for-cdn.sh
# bash tools/package-for-cdn.sh --build build/web-desktop-001 --out "/Users/liuyufei/tfrh/竞赛/mstest5-cocos"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/build/web-desktop"
OUT_DIR="$PROJECT_DIR/build/mstest5"
UNITY_REF="/Users/liuyufei/tfrh/竞赛/mstest5"
while [[ $# -gt 0 ]]; do
case "$1" in
--build) BUILD_DIR="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;;
--unity-ref) UNITY_REF="$2"; shift 2 ;;
--to-unity-dir) OUT_DIR="/Users/liuyufei/tfrh/竞赛/mstest5-cocos"; shift ;;
-h|--help)
cat <<'EOF'
Usage: package-for-cdn.sh [--build DIR] [--out DIR] [--unity-ref DIR]
默认:
--build build/web-desktop
--out build/mstest5
--unity-ref /Users/liuyufei/tfrh/竞赛/mstest5
产物与 Unity 参考完全一致:
index.html
Build/mstest5.{loader.js,data.br,framework.js.br,wasm.br}
StreamingAssets/aa/{catalog.json,settings.json,AddressablesLink/link.xml,WebGL/*.bundle}
TemplateData/*
EOF
exit 0
;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
BUILD_DIR="$(cd "$BUILD_DIR" 2>/dev/null && pwd || true)"
if [[ -z "$BUILD_DIR" || ! -f "$BUILD_DIR/index.html" ]]; then
echo "Error: 请先 Cocos 构建 Web Desktop目录: $BUILD_DIR"
exit 1
fi
if [[ ! -f "$UNITY_REF/index.html" ]]; then
echo "Error: Unity 参考包不存在: $UNITY_REF"
exit 1
fi
node "$SCRIPT_DIR/package-for-cdn.js" "$BUILD_DIR" "$OUT_DIR" "$UNITY_REF"
echo ""
echo "对比 Unity 参考:"
echo " diff -qr \"$UNITY_REF\" \"$OUT_DIR\" | head -30"

View File

@@ -0,0 +1,273 @@
#!/usr/bin/env node
/**
* 项目运行包:扁平结构,直接供 scratch-gui static/unity 本地加载。
*
* node tools/package-for-project.js <cocosBuildDir> <outDir> [options]
*
* 选项:
* --minify-db 压缩 levels-database.json默认开启
* --no-minify-db 保留格式化 JSON
* --brotli-db 额外生成 levels-database.json.br
* --preload-resources 启动时预加载 resources 包(默认关闭,按需加载)
* --preload-levels 启动时预加载 level-prefabs 包(默认关闭)
* --report 打印体积报告(默认开启)
* --no-report
*
* 输出结构:
* Build/mstest5.loader.js + 占位 .br
* index.js, application.js, cocos-bridge.js
* cocos-js/, src/, assets/
*/
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const {
minifyLevelsDatabase,
brotliCompressFile,
patchPreloadSettings,
printPackageReport,
formatBytes,
} = require('./package-optimize');
const argv = process.argv.slice(2);
const positional = [];
const flags = new Set();
for (const a of argv) {
if (a.startsWith('--')) flags.add(a);
else positional.push(a);
}
const buildDir = path.resolve(positional[0] || '');
const outDir = path.resolve(positional[1] || '');
const opts = {
minifyDb: !flags.has('--no-minify-db'),
brotliDb: flags.has('--brotli-db'),
preloadResources: flags.has('--preload-resources'),
preloadLevelPrefabs: flags.has('--preload-levels'),
report: !flags.has('--no-report'),
};
const PRODUCT = 'mstest5';
const UNITY_BASE = '/unity/';
const templateDir = path.join(__dirname, '../web-template');
if (!buildDir || !outDir) {
console.error('Usage: package-for-project.js <cocosBuildDir> <outDir> [--minify-db] [--brotli-db] ...');
process.exit(1);
}
function rmrf(p) {
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
function copyFile(s, d) { mkdirp(path.dirname(d)); fs.copyFileSync(s, d); }
function copyDir(s, d) {
if (!fs.existsSync(s)) return;
mkdirp(d);
for (const ent of fs.readdirSync(s, { withFileTypes: true })) {
const sp = path.join(s, ent.name);
const dp = path.join(d, ent.name);
if (ent.isDirectory()) copyDir(sp, dp);
else copyFile(sp, dp);
}
}
function patchText(file, fn) {
const t = fs.readFileSync(file, 'utf8');
fs.writeFileSync(file, fn(t), 'utf8');
}
function brotliStub(dst) {
fs.writeFileSync(dst, zlib.brotliCompressSync(Buffer.from([0])));
}
const required = ['index.js', 'application.js', 'src', 'cocos-js', 'assets'];
for (const r of required) {
if (!fs.existsSync(path.join(buildDir, r))) {
console.error('Missing in Cocos build:', r, 'at', buildDir);
process.exit(1);
}
}
console.log('>>> Cocos 构建:', buildDir);
console.log('>>> 输出(项目运行包):', outDir);
console.log('>>> 优化:', JSON.stringify({
minifyDb: opts.minifyDb,
brotliDb: opts.brotliDb,
preloadResources: opts.preloadResources,
preloadLevelPrefabs: opts.preloadLevelPrefabs,
}));
mkdirp(outDir);
for (const item of [
'Build', 'index.js', 'application.js', 'cocos-bridge.js',
'cocos-js', 'src', 'assets',
'StreamingAssets', 'TemplateData', 'index.html',
'levels-database.json', 'levels-database.json.br',
]) {
rmrf(path.join(outDir, item));
}
for (const item of ['index.js', 'application.js']) {
copyFile(path.join(buildDir, item), path.join(outDir, item));
}
copyDir(path.join(buildDir, 'src'), path.join(outDir, 'src'));
copyDir(path.join(buildDir, 'cocos-js'), path.join(outDir, 'cocos-js'));
copyDir(path.join(buildDir, 'assets'), path.join(outDir, 'assets'));
const EMBEDDED_RESOLUTION_PATCH =
'!(function(){v.resizeWithBrowserSize(!0);v.setDesignResolutionSize(G,H,C.FIXED_WIDTH)})()';
function patchMainIndexRuntime(t) {
return t
.replace(/return\[\]\.concat\(l\)\},entityFlipX/g, 'return Array.from(l)},entityFlipX')
.replace(/return\[\]\.concat\(n\)\},e\.ensureUILayerTree/g, 'return Array.from(n)},e.ensureUILayerTree')
.replace(/e\("applyEmbeddedDesignResolution",\(function\(\)\{[^}]+\}\)\)/g, 'e("applyEmbeddedDesignResolution",(function(){t.resizeWithBrowserSize(!0),t.setDesignResolutionSize(s,o,n.FIXED_WIDTH)}))')
.replace(/v\.setDesignResolutionSize\(G,H,C\.(?:SHOW_ALL|FIXED_WIDTH|FIXED_HEIGHT)\)/g, EMBEDDED_RESOLUTION_PATCH)
.replace(/setDesignResolutionSize\(G,H,C\.(?:SHOW_ALL|FIXED_WIDTH|FIXED_HEIGHT)\)/g, EMBEDDED_RESOLUTION_PATCH)
.replace(
/e\.syncMapDataComponent=function\(e,n\)\{var r,t,o=e\.getComponent\(p\);o&&\(o\.levelID=n\.levelID,o\.theme=T\(n\),o\.groundJson=JSON\.stringify\(null!=\(r=n\.ground\)\?r:\{\}\),o\.borderJson=JSON\.stringify\(null!=\(t=n\.border\)\?t:\{\}\)\)\}/,
'e.syncMapDataComponent=function(e,n){var r,t,o=e.getComponent(p);if(!o)return;o.levelID=n.levelID;var i=n.theme&&String(n.theme).trim();o.theme=i?T(n):o.theme||T(n);r=n.ground;r&&Object.keys(r).length&&(o.groundJson=JSON.stringify(r));t=n.border;t&&Object.keys(t).length&&(o.borderJson=JSON.stringify(t))}',
)
.replace(
/e\.prepare=function\(\)\{var e=r\(\(function\*\(e,n\)\{e\.name="Level_"\+n\.levelID,f\.purgeRuntimeGrids\(e\),this\.ensureUILayerTree\(e\);var r=T\(n\),t=m\(e,n\);/,
'e.prepare=function(){var e=r((function*(e,n){e.name="Level_"+n.levelID,f.purgeRuntimeGrids(e),this.ensureUILayerTree(e);var _md=e.getComponent(p);if(_md){try{var _g=JSON.parse(_md.groundJson||"{}");(!n.ground||!Object.keys(n.ground).length)&&Object.keys(_g).length&&(n.ground=_g);var _b=JSON.parse(_md.borderJson||"{}");(!n.border||!Object.keys(n.border).length)&&Object.keys(_b).length&&(n.border=_b)}catch(_e){}(!n.theme||!String(n.theme).trim())&&_md.theme&&(n.theme=_md.theme)}var r=T(n),t=m(e,n);',
)
.replace(
/function p\(e\)\{var r=e\.children\.filter\(\(function\(e\)\{return e\.isValid&&y\.test\(e\.name\)\}\)\);/,
'function p(e){if(!e||!e.isValid||!e.children)return;var r=e.children.filter((function(e){return e.isValid&&y.test(e.name)}));',
)
.replace(
/this\.applyMapDataFromConfig\(n\),yield J\.prepare\(o,n\);var s=n\.theme\|\|"silu";/,
'yield J.prepare(o,n),this.applyMapDataFromConfig(n),this.curConfig=n;var s=n.theme||"silu";',
)
.replace(
/c\.moveTowards\(i,e,this\.targetPosition,this\.moveSpeed\*t\)/g,
'c.moveTowards(i,e,this.targetPosition,this.moveSpeed*t*((null!=(S=globalThis.__tfrhGameSpeed)&&S>0)?S:1))',
)
.replace(
/if\(!\(t<=0\)\)if\(this\.frameTimer\+=e,!\(this\.frameTimer<t\)\)/,
'if(!(t<=0))if(this.frameTimer+=e*((null!=(z=globalThis.__tfrhGameSpeed)&&z>0)?z:1),!(this.frameTimer<t))',
)
.replace(
/a\.onPlaySpeedChange=function\(\)\{this\.speedIndex=\(this\.speedIndex\+1\)%l\.SPEEDS\.length,d\.globalGameTimeScale=l\.SPEEDS\[this\.speedIndex\],this\.setPlaySpeedSprite\(this\.resolveHudTheme\(\)\)\}/,
'a.onPlaySpeedChange=function(){this.speedIndex=(this.speedIndex+1)%l.SPEEDS.length;var e=l.SPEEDS[this.speedIndex];d.globalGameTimeScale=e,globalThis.__tfrhGameSpeed=e,console.log("[UIMain] 倍速 x"+e),this.setPlaySpeedSprite(this.resolveHudTheme())}',
)
.replace(
/a\.onLoad=function\(\)\{d\.globalGameTimeScale=l\.SPEEDS\[this\.speedIndex\],this\.buildUI\(\)/,
'a.onLoad=function(){d.globalGameTimeScale=l.SPEEDS[this.speedIndex],globalThis.__tfrhGameSpeed=l.SPEEDS[this.speedIndex],this.buildUI()',
)
.replace(
/a\.onRevertGame=function\(\)\{var e;this\.speedIndex=0,d\.globalGameTimeScale=l\.SPEEDS\[this\.speedIndex\],null==\(e=w\.instance\)\|\|e\.resetLevel\(\)\}/,
'a.onRevertGame=function(){var e;this.speedIndex=0,d.globalGameTimeScale=l.SPEEDS[this.speedIndex],globalThis.__tfrhGameSpeed=l.SPEEDS[this.speedIndex],null==(e=w.instance)||e.resetLevel()}',
);
}
const mainIndex = path.join(outDir, 'assets', 'main', 'index.js');
if (fs.existsSync(mainIndex)) {
patchText(mainIndex, (t) => patchMainIndexRuntime(t));
}
copyFile(
path.join(templateDir, 'cocos-bridge.js'),
path.join(outDir, 'cocos-bridge.js'),
);
const levelsDbSrc = path.join(__dirname, '../assets/level-data/levels-database.json');
const levelsDbDst = path.join(outDir, 'levels-database.json');
if (fs.existsSync(levelsDbSrc)) {
if (opts.minifyDb) {
const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst);
console.log(`>>> levels-database.json 压缩: ${formatBytes(before)}${formatBytes(after)}`);
} else {
copyFile(levelsDbSrc, levelsDbDst);
}
if (opts.brotliDb) {
const { raw, br } = brotliCompressFile(levelsDbDst, path.join(outDir, 'levels-database.json.br'));
console.log(`>>> levels-database.json.br: ${formatBytes(raw)}${formatBytes(br)}`);
}
console.log('>>> 已附带 levels-database.jsonCocos 关卡库)');
} else {
console.warn('>>> 警告: 未找到 levels-database.json请先运行 tools/sync-level-db.sh');
}
patchText(path.join(outDir, 'application.js'), (t) =>
t.replace(/this\.settingsPath\s*=\s*'[^']*'/, `this.settingsPath = '${UNITY_BASE}src/settings.json'`),
);
function patchSettingsForFrontend(settingsFile) {
patchText(settingsFile, (t) => {
const j = JSON.parse(t);
j.assets = j.assets || {};
j.assets.server = UNITY_BASE;
patchPreloadSettings(j, {
preloadResources: opts.preloadResources,
preloadLevelPrefabs: opts.preloadLevelPrefabs,
});
if (j.rendering) {
j.rendering.effectSettingsPath = `${UNITY_BASE}src/effect.bin`;
}
if (j.scripting && Array.isArray(j.scripting.scriptPackages)) {
j.scripting.scriptPackages = j.scripting.scriptPackages.map((p) => {
const rel = String(p).replace(/^\.\.\//, '').replace(/^\//, '');
return `${UNITY_BASE}${rel}`;
});
}
return JSON.stringify(j);
});
}
const settingsPath = path.join(outDir, 'src', 'settings.json');
if (fs.existsSync(settingsPath)) {
patchSettingsForFrontend(settingsPath);
const preload = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).assets?.preloadBundles || [];
console.log('>>> 预加载 bundles:', preload.map((b) => b.bundle).join(', ') || '(none)');
}
const importMapPath = path.join(outDir, 'src', 'import-map.json');
if (fs.existsSync(importMapPath)) {
patchText(importMapPath, (t) => {
const j = JSON.parse(t);
j.imports = j.imports || {};
if (j.imports.cc) j.imports.cc = `${UNITY_BASE}cocos-js/cc.js`;
return JSON.stringify(j);
});
}
const buildOut = path.join(outDir, 'Build');
mkdirp(buildOut);
const loaderTpl = fs.readFileSync(path.join(templateDir, 'mstest5-cocos-loader.js'), 'utf8');
fs.writeFileSync(
path.join(buildOut, `${PRODUCT}.loader.js`),
loaderTpl.replace(/__PRODUCT_NAME__/g, PRODUCT),
'utf8',
);
for (const stub of ['data', 'framework.js', 'wasm']) {
brotliStub(path.join(buildOut, `${PRODUCT}.${stub}.br`));
}
if (opts.report) {
const preloadNote = [
'main',
opts.preloadResources ? 'resources' : null,
opts.preloadLevelPrefabs ? 'level-prefabs' : null,
].filter(Boolean).join(', ');
printPackageReport(outDir, { preloadNote });
const hasLevelBundle = fs.existsSync(path.join(outDir, 'assets', 'level-prefabs'));
if (!hasLevelBundle) {
console.log('\n>>> 提示: 尚未拆分 level-prefabs 分包。运行 migrate-level-prefab-bundle.sh 后重建可显著减小 resources 体积。');
}
}
console.log('\n>>> 项目运行包文件:');
for (const rel of [
'Build/mstest5.loader.js',
'index.js',
'application.js',
'cocos-bridge.js',
'cocos-js/',
'src/',
'assets/',
'levels-database.json',
]) {
console.log(' ', rel);
}
console.log('\n完成。flat 包与 CDN 不一致,生产请用: bash tools/package-for-project.sh');
console.log(' ~/tfrh/001code/001code-html/scratch-gui/static/unity/import-to-unity.sh', outDir);

111
tools/package-for-project.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# 步骤 1打包 — 单一运行时包,本地与 CDN 完全一致
#
# bash tools/package-for-project.sh
#
# 产物:
# build/mstest5/ 运行时包(← 本地 import + OSS 上传,内容相同)
# build/deploy/ 上传清单(不进 OSS / static
# build/standalone-player/ 独立调试页(可选,不进 OSS / static
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/build/web-desktop"
MODE="cdn"
OUT_DIR="$PROJECT_DIR/build/mstest5"
DEPLOY_DIR="$PROJECT_DIR/build/deploy"
CDN_BASE=""
PKG_EXTRA=()
CDN_REF="${COCOS_CDN_UNITY_REF:-$HOME/tfrh/竞赛/mstest5}"
SKIP_MANIFEST=0
MAKE_ZIP=0
usage() {
echo "步骤 1 - 打包(单一运行时包)" >&2
echo "" >&2
echo "用法: $0 [options]" >&2
echo " --build DIR Cocos 构建目录(默认 build/web-desktop" >&2
echo " --out DIR 运行时包目录(默认 build/mstest5" >&2
echo " --mode cdn|flat cdn=默认; flat=旧扁平包(不推荐)" >&2
echo " --cdn-base URL 写入 deploy/UPLOAD-MANIFEST.txt" >&2
echo " --skip-manifest 不生成 deploy/ 清单" >&2
echo " --zip 额外生成 build/mstest5-runtime.zip" >&2
echo "" >&2
echo "默认分包: assets_all 首屏含 level-prefabs 壳;每关独立 bundle 进关按需" >&2
echo " MERGE_LEVELS=1 合并 level-prefabs 进 assets_all不推荐" >&2
echo "运行时包结构(本地 static/unity = OSS unitycdndir:" >&2
echo " Build/ StreamingAssets/ levels-database.json(.br)" >&2
echo "" >&2
echo "步骤 2: scratch-gui/static/unity/import-to-unity.sh" >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--build) BUILD_DIR="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;;
--mode) MODE="$2"; shift 2 ;;
--cdn-base) CDN_BASE="$2"; shift 2 ;;
--skip-manifest) SKIP_MANIFEST=1; shift ;;
--zip) MAKE_ZIP=1; shift ;;
--brotli-db) PKG_EXTRA+=(--brotli-db); shift ;;
--preload-resources) PKG_EXTRA+=(--preload-resources); shift ;;
--no-minify-db) PKG_EXTRA+=(--no-minify-db); shift ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage; exit 1 ;;
esac
done
[[ -f "$BUILD_DIR/index.js" || -f "$BUILD_DIR/index.html" ]] || {
echo "错误: 请先 Cocos 构建 Web Desktop: $BUILD_DIR" >&2
exit 1
}
DB="$PROJECT_DIR/assets/level-data/levels-database.json"
if [[ ! -f "$DB" ]] || [[ "$(python3 -c "import json; d=json.load(open('$DB')); print(len(d.get('levels',{})))" 2>/dev/null || echo 0)" -lt 100 ]]; then
echo "==> 从 Cocos 导出关卡库"
bash "$SCRIPT_DIR/sync-level-db.sh"
fi
case "$MODE" in
cdn)
CDN_REF="$(cd "$CDN_REF" 2>/dev/null && pwd || true)"
if [[ -z "$CDN_REF" || ! -f "$CDN_REF/StreamingAssets/aa/catalog.json" ]]; then
echo "错误: 缺少 Unity 参考包: $CDN_REF" >&2
exit 1
fi
# 移除旧的双份产物
rm -rf "$PROJECT_DIR/build/cdn-upload" "$PROJECT_DIR/build/cdn-upload.zip" 2>/dev/null || true
echo "==> [1/2] 打运行时包"
bash "$SCRIPT_DIR/verify-split-build.sh" "$BUILD_DIR"
node "$SCRIPT_DIR/package-for-cdn.js" "$BUILD_DIR" "$OUT_DIR" "$CDN_REF"
if [[ "$SKIP_MANIFEST" -eq 0 ]]; then
echo "==> [2/2] 生成 OSS 上传清单build/deploy/,不复制包)"
MANIFEST_ARGS=("$OUT_DIR" --manifest-dir "$DEPLOY_DIR")
[[ -n "$CDN_BASE" ]] && MANIFEST_ARGS+=(--cdn-base "$CDN_BASE")
[[ "$MAKE_ZIP" -eq 1 ]] && MANIFEST_ARGS+=(--zip)
node "$SCRIPT_DIR/write-deploy-manifest.js" "${MANIFEST_ARGS[@]}"
fi
;;
flat)
OUT_DIR="${OUT_DIR:-$PROJECT_DIR/build/unity-runtime}"
echo "==> [警告] flat 模式与 CDN 不一致" >&2
if ((${#PKG_EXTRA[@]} > 0)); then
node "$SCRIPT_DIR/package-for-project.js" "$BUILD_DIR" "$OUT_DIR" "${PKG_EXTRA[@]}"
else
node "$SCRIPT_DIR/package-for-project.js" "$BUILD_DIR" "$OUT_DIR"
fi
;;
*)
echo "错误: --mode 须为 cdn 或 flat" >&2
exit 1
;;
esac
echo ""
echo "==> 运行时包: $OUT_DIR"
echo " 本地: import-to-unity.sh"
echo " CDN: 上传 $OUT_DIR/ 全部 → unitycdndir"
[[ "$SKIP_MANIFEST" -eq 0 && "$MODE" == "cdn" ]] && echo " 清单: $DEPLOY_DIR/UPLOAD-MANIFEST.txt"

106
tools/package-optimize.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* 打包优化关卡库压缩、settings 预加载策略、体积报告。
* 供 package-for-project.js / package-for-cdn.js 共用。
*/
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
function formatBytes(n) {
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${n} B`;
}
function dirSize(root) {
if (!fs.existsSync(root)) return 0;
let total = 0;
for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
const p = path.join(root, ent.name);
if (ent.isDirectory()) total += dirSize(p);
else total += fs.statSync(p).size;
}
return total;
}
function fileSize(p) {
return fs.existsSync(p) ? fs.statSync(p).size : 0;
}
/** 压缩关卡库 JSON去掉空白不改变语义 */
function minifyLevelsDatabase(srcPath, dstPath) {
const raw = fs.readFileSync(srcPath, 'utf8');
const data = JSON.parse(raw);
const compact = JSON.stringify(data);
fs.writeFileSync(dstPath, compact, 'utf8');
return { before: Buffer.byteLength(raw, 'utf8'), after: Buffer.byteLength(compact, 'utf8') };
}
/** 可选:生成 .br 供静态服务器直接返回 */
function brotliCompressFile(srcPath, dstPath, quality = 9) {
const input = fs.readFileSync(srcPath);
const out = zlib.brotliCompressSync(input, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: quality },
});
fs.writeFileSync(dstPath, out);
return { raw: input.length, br: out.length };
}
/**
* 调整预加载 bundle默认只预加载 mainresources / level-prefabs 按需加载。
* @param {object} opts
* @param {boolean} [opts.preloadResources=false]
* @param {boolean} [opts.preloadLevelPrefabs=false]
*/
function patchPreloadSettings(settingsObj, opts = {}) {
const preloadResources = opts.preloadResources === true;
const preloadLevelPrefabs = opts.preloadLevelPrefabs === true;
const bundles = settingsObj.assets?.projectBundles || [];
const preload = [{ bundle: 'main' }];
if (preloadResources && bundles.includes('resources')) {
preload.push({ bundle: 'resources' });
}
if (preloadLevelPrefabs && bundles.includes('level-prefabs')) {
preload.push({ bundle: 'level-prefabs' });
}
settingsObj.assets = settingsObj.assets || {};
settingsObj.assets.preloadBundles = preload;
return settingsObj;
}
function printPackageReport(outDir, opts = {}) {
const lines = ['\n>>> 包体积报告:'];
const entries = [
['cocos-js', path.join(outDir, 'cocos-js')],
['src', path.join(outDir, 'src')],
['assets/main', path.join(outDir, 'assets', 'main')],
['assets/resources', path.join(outDir, 'assets', 'resources')],
['assets/level-prefabs', path.join(outDir, 'assets', 'level-prefabs')],
['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')],
['Build', path.join(outDir, 'Build')],
];
let total = 0;
for (const [label, p] of entries) {
if (!fs.existsSync(p)) continue;
const sz = fs.statSync(p).isDirectory() ? dirSize(p) : fileSize(p);
if (sz <= 0) continue;
total += sz;
lines.push(` ${label.padEnd(28)} ${formatBytes(sz)}`);
}
const grand = dirSize(outDir);
lines.push(` ${'合计(顶层目录)'.padEnd(28)} ${formatBytes(grand)}`);
if (opts.preloadNote) lines.push(` 预加载: ${opts.preloadNote}`);
console.log(lines.join('\n'));
}
module.exports = {
formatBytes,
dirSize,
fileSize,
minifyLevelsDatabase,
brotliCompressFile,
patchPreloadSettings,
printPackageReport,
};

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* 热修补已构建 main/index.js 中主题/贴图路径对非字符串 .trim() / .replace() 崩溃的问题。
* Cocos Creator 重新构建后可由 deploy-to-001code.sh 自动调用。
*/
const fs = require('fs');
const path = require('path');
const REPLACEMENTS = [
[
'function D(e){if(null==e||!e.trim())return"silu";var t=e.trim();',
'function D(e){var t=String(null==e?"":e).trim();if(!t)return"silu";',
],
[
'getThemeTilePath:function(e,t){var r=D(e),n=J(r);if(!n)return;var l="string"==typeof t?t.trim():null==t?"":String(t);if(!l)return;',
'getThemeTilePath:function(e,t){var r=D(e),n=J(r);if(!n)return;var l=String(null==t?"":t).trim();if(!l)return;',
],
[
'function M(e){var t,r=I(e);return(null==r||null==(t=r.borderDecorKey)?void 0:t.trim())||void 0}',
'function M(e){var r,n=I(D(e)),o=null==n?void 0:n.borderDecorKey,t="string"==typeof o?o.trim():null==o?"":String(o);return t||void 0}',
],
[
'var l=null==t?void 0:t.trim();if(!l)return;',
'var l="string"==typeof t?t.trim():null==t?"":String(t);if(!l)return;',
],
[
'pathCacheKey=function(e){return e.replace(/\\\\/g,"/")}',
'pathCacheKey=function(e){var t="string"==typeof e?e:null==e?"":String(e);return t.replace(/\\\\/g,"/")}',
],
[
'getThemeBackground:function(e){var t,r=I(e);return(null==r||null==(t=r.background)?void 0:t.trim())||void 0}',
'getThemeBackground:function(e){var t,r=I(e),n=null==r?void 0:r.background,o="string"==typeof n?n.trim():null==n?"":String(n);return o||void 0}',
],
[
'function(e){if(e){var n=e.trim();return"redArmy"===n?"redarmy":n}}',
'function(e){if(null==e)return;var n="string"==typeof e?e.trim():String(e);if(!n)return;return"redArmy"===n?"redarmy":n}',
],
[
'function g(e,t){var r,n=null==(r=Y(e)[t])?void 0:r.trim();return n||y.silu[t]}',
'function g(e,t){var r,n=Y(e)[t],o="string"==typeof n?n.trim():null==n?"":String(n);return o||y.silu[t]}',
],
[
'o=null==u||null==(r=u.portrait)?void 0:r.trim()',
'o=null==u?void 0:(r=u.portrait,r="string"==typeof r?r.trim():null==r?"":String(r),r||void 0)',
],
[
'ull==u||null==(n=u.playerFront)?void 0:n.trim()',
'ull==u?void 0:(n=u.playerFront,n="string"==typeof n?n.trim():null==n?"":String(n),n||void 0)',
],
[
'null==(t=r.cocosPrefab)?void 0:t.trim()',
'(t=r.cocosPrefab,t="string"==typeof t?t.trim():null==t?"":String(t),t||void 0)',
],
];
function patchFile(file) {
if (!fs.existsSync(file)) {
console.log(' skip (missing):', file);
return false;
}
let src = fs.readFileSync(file, 'utf8');
let changed = false;
for (const [from, to] of REPLACEMENTS) {
if (src.includes(from)) {
src = src.replace(from, to);
changed = true;
}
}
const before = src;
src = src.replace(
/"string"==typeof ([a-zA-Z_$][\w$]*)\?\1\.trim\(\):null==\1\?"":String\(\1\)/g,
'String(null==$1?"":$1).trim()',
);
src = src.replace(
/function D\(e\)\{var t="string"==typeof e\?e\.trim\(\):null==e\?"":String\(e\);/g,
'function D(e){var t=String(null==e?"":e).trim();',
);
if (src !== before) changed = true;
if (!changed) {
if (src.includes('String(null==t?"":t).trim()') && src.includes('pathCacheKey=function(e){var t="string"==typeof e')) {
console.log(' already patched:', file);
return true;
}
console.warn(' no match:', file);
return false;
}
fs.writeFileSync(file, src, 'utf8');
console.log(' patched:', file);
return true;
}
const targets = process.argv.slice(2);
if (!targets.length) {
console.error('Usage: node patch-theme-trim-hotfix.js <main/index.js> [...]');
process.exit(1);
}
let ok = true;
for (const t of targets) {
if (!patchFile(path.resolve(t))) ok = false;
}
process.exit(ok ? 0 : 1);

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""用 Unity 源贴图 MD5 匹配恢复 __stage__并完成 sanxing 命名统一。"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TEX = ROOT / "assets" / "resources" / "textures"
UNITY_TEX = Path("/Users/liuyufei/tfrh/竞赛/scratch-unity-base/Assets/Texture")
# 复用 unify 脚本的 RENAMES
import importlib.util
_spec = importlib.util.spec_from_file_location(
"unify", ROOT / "tools" / "unify-theme-texture-names.py"
)
_unify = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_unify)
RENAMES = _unify.RENAMES
_norm_rel = _unify._norm_rel
build_rename_lookup = _unify.build_rename_lookup
move_pair = _unify.move_pair
apply_renames = _unify.apply_renames
cleanup_empty_dirs = _unify.cleanup_empty_dirs
move_legacy = _unify.move_legacy
SANXING_KEEP = _unify.SANXING_KEEP
THEME_SHIP = _unify.THEME_SHIP
build_path_replacements = _unify.build_path_replacements
patch_text_files = _unify.patch_text_files
patch_themes_database = _unify.patch_themes_database
patch_palettes = _unify.patch_palettes
UNITY_SOURCES: dict[str, list[Path]] = {
"silu": [UNITY_TEX / "silu"],
"numMan": [UNITY_TEX / "numMan"],
"redArmy": [UNITY_TEX / "redarmy"],
"chinese": [UNITY_TEX / "Panda", UNITY_TEX / "Chinese"],
"snow": [TEX / "snow"], # snow 仅 Cocos从现有目录取
"sanxing": [TEX / "sanxing"],
}
def md5_file(path: Path) -> str:
h = hashlib.md5()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def build_unity_hash_index(theme: str) -> dict[str, str]:
"""md5 -> theme 内相对路径(不含 .png"""
index: dict[str, str] = {}
for src_root in UNITY_SOURCES.get(theme, []):
if not src_root.exists():
continue
for png in src_root.rglob("*.png"):
rel = png.relative_to(src_root).as_posix().replace(".png", "")
# chinese: Panda/待机/机器人 -> skin/待机/机器人 便于 lookup
if theme == "chinese" and rel.startswith("Panda/"):
rel = "skin/" + rel[len("Panda/"):]
digest = md5_file(png)
index[digest] = rel
return index
def recover_stages_by_hash() -> int:
lookup = build_rename_lookup()
recovered = 0
for stage in sorted(TEX.rglob("__stage_*.png")):
theme_key = stage.relative_to(TEX).parts[0]
digest = md5_file(stage)
rel = build_unity_hash_index(theme_key).get(digest)
if not rel:
continue
new_rel = lookup.get((theme_key, Path(rel).name)) or lookup.get((theme_key, rel))
if not new_rel:
continue
dst = TEX / theme_key / f"{new_rel}.png"
if move_pair(stage, dst, skip_if_dst_exists=True):
recovered += 1
print(f" hash-recovered {theme_key}: {rel} -> {new_rel}.png")
return recovered
def copy_from_unity_if_missing(theme: str) -> int:
"""目标路径缺失时从 Unity 复制并重命名"""
lookup = build_rename_lookup()
count = 0
for src_root in UNITY_SOURCES.get(theme, []):
if not src_root.exists() or src_root == TEX / theme:
continue
for png in src_root.rglob("*.png"):
rel = png.relative_to(src_root).as_posix().replace(".png", "")
if theme == "chinese" and rel.startswith("Panda/"):
rel = "skin/" + rel[len("Panda/"):]
key_name = Path(rel).name
new_rel = lookup.get((theme, key_name)) or lookup.get((theme, rel))
if not new_rel:
continue
dst = TEX / theme / f"{new_rel}.png"
if dst.exists():
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(png, dst)
count += 1
print(f" copied {theme}: {rel} -> {new_rel}.png")
return count
def remove_orphan_stages() -> None:
legacy_root = TEX / "_orphan_stages"
for stage in TEX.rglob("__stage_*.png"):
rel = stage.relative_to(TEX)
dest = legacy_root / rel
dest.parent.mkdir(parents=True, exist_ok=True)
move_pair(stage, dest)
def main() -> None:
print("=== MD5 恢复 __stage__ ===")
n_hash = recover_stages_by_hash()
print(f" 恢复 {n_hash}")
print("=== 从 Unity 补全缺失文件 ===")
n_copy = 0
for theme in ["silu", "numMan", "redArmy", "chinese"]:
n_copy += copy_from_unity_if_missing(theme)
print(f" 复制 {n_copy}")
print("=== 常规重命名 ===")
n = apply_renames()
print(f" 重命名 {n}")
print("=== 清理 ===")
remove_orphan_stages()
for theme in ["silu", "snow", "numMan", "redArmy", "chinese"]:
cleanup_empty_dirs(TEX / theme)
keep = set(SANXING_KEEP) | set(THEME_SHIP.get(theme, ()))
move_legacy(theme, keep)
print("=== 更新引用 ===")
patch_text_files(build_path_replacements())
patch_themes_database()
patch_palettes()
print("完成。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
将 level-prefabs/Level{N}.prefab 重命名为 Level{91600+N}.prefab并同步 JSON 数据库。
与 Unity / 主站 config.js BEGINNING_REAL_LVID=91601 对齐:游戏 ID 从 91601 起(首关 Level91601
用法:
python3 tools/renumber-level-prefabs.py --dry-run
python3 tools/renumber-level-prefabs.py
"""
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from level_id import (
LEVEL_ID_BASE,
internal_level_index as to_internal_id,
prefab_resource_path,
touch_database,
)
def to_external_id(internal: int) -> int:
return LEVEL_ID_BASE + internal
PREFAB_DIR_NAME = "level-prefabs"
LEVEL_RE = re.compile(r"^Level(\d+)\.prefab$", re.I)
def rename_prefabs(prefab_dir: Path, dry_run: bool) -> dict[int, int]:
"""internal -> external mapping for renamed files."""
mapping: dict[int, int] = {}
files = sorted(
[p for p in prefab_dir.glob("Level*.prefab") if LEVEL_RE.match(p.name)],
key=lambda p: int(LEVEL_RE.match(p.name).group(1)), # type: ignore
reverse=True,
)
for src in files:
m = LEVEL_RE.match(src.name)
if not m:
continue
internal = int(m.group(1))
if internal > LEVEL_ID_BASE:
continue
external = to_external_id(internal)
dst = prefab_dir / f"Level{external}.prefab"
meta_src = Path(str(src) + ".meta")
meta_dst = Path(str(dst) + ".meta")
mapping[internal] = external
if dry_run:
if internal <= 3 or internal in (60, 80, 4188):
print(f" {src.name} -> {dst.name}")
continue
if dst.exists():
raise SystemExit(f"目标已存在,中止: {dst}")
src.rename(dst)
if meta_src.is_file():
meta_src.rename(meta_dst)
return mapping
def migrate_levels_database(db_path: Path, mapping: dict[int, int], dry_run: bool) -> None:
if not db_path.is_file():
print(f"skip database (not found): {db_path}")
return
data = json.loads(db_path.read_text(encoding="utf-8"))
old_levels = data.get("levels") or {}
new_levels: dict[str, dict] = {}
for key, cfg in old_levels.items():
try:
old_id = int(key)
except ValueError:
old_id = int(cfg.get("levelID", key))
internal = to_internal_id(old_id) if old_id > LEVEL_ID_BASE else old_id
external = mapping.get(internal, to_external_id(internal))
entry = dict(cfg)
entry["levelID"] = external
unity_prefab = entry.get("unityPrefab", "")
if isinstance(unity_prefab, str) and unity_prefab:
entry["unityPrefab"] = re.sub(
r"Level\d+\.prefab$",
f"Level{external}.prefab",
unity_prefab.replace("\\", "/"),
flags=re.I,
)
entry["cocosPrefab"] = prefab_resource_path(external)
new_levels[str(external)] = entry
data["levels"] = new_levels
if dry_run:
print(f"database: {len(old_levels)} -> {len(new_levels)} keys:", sorted(new_levels.keys()))
return
db_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
touch_database(db_path)
print(f"updated {db_path}")
def rebuild_prefab_index(prefab_dir: Path, index_path: Path, dry_run: bool) -> None:
index: dict[str, str] = {}
for p in sorted(
prefab_dir.glob("Level*.prefab"),
key=lambda x: int(LEVEL_RE.match(x.name).group(1)), # type: ignore
):
m = LEVEL_RE.match(p.name)
if not m:
continue
lid = int(m.group(1))
index[str(lid)] = f"{PREFAB_DIR_NAME}/Level{lid}"
if dry_run:
print(f"index: {len(index)} entries")
return
index_path.write_text(json.dumps(index, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"updated {index_path} ({len(index)} entries)")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--project", type=Path, default=Path(__file__).resolve().parents[1])
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
project = args.project
prefab_dir = project / "assets" / "resources" / PREFAB_DIR_NAME
db_path = project / "assets" / "level-data" / "levels-database.json"
index_path = project / "tools" / "level-prefab-index.json"
if not prefab_dir.is_dir():
raise SystemExit(f"prefab dir not found: {prefab_dir}")
print(f"{'[dry-run] ' if args.dry_run else ''}Renaming prefabs in {prefab_dir}")
mapping = rename_prefabs(prefab_dir, args.dry_run)
print(f" mapped {len(mapping)} prefabs (1 -> {to_external_id(1)})")
migrate_levels_database(db_path, mapping, args.dry_run)
rebuild_prefab_index(prefab_dir, index_path, args.dry_run)
print("done.")
if __name__ == "__main__":
main()

95
tools/runtime-pack.js Normal file
View File

@@ -0,0 +1,95 @@
/**
* 统一运行时包定义 — 本地 static/unity 与 OSS unitycdndir 内容完全一致。
*
* 运行时包(唯一真相):
* Build/
* StreamingAssets/
* levels-database.json
* levels-database.json.br
*
* 不含 index.html / TemplateData仅 standalone-player 独立调试页使用)
*/
const fs = require('fs');
const path = require('path');
const RUNTIME_ROOT_FILES = ['levels-database.json', 'levels-database.json.br'];
const RUNTIME_DIRS = ['Build', 'StreamingAssets'];
function walkFiles(root, bucket, prefix = '') {
if (!fs.existsSync(root)) return;
for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
if (ent.name === '.DS_Store') continue;
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
const full = path.join(root, ent.name);
if (ent.isDirectory()) walkFiles(full, bucket, rel);
else bucket.push({ rel, full, size: fs.statSync(full).size });
}
}
/** 列出运行时包内所有相对路径(用于 manifest / 校验) */
function listRuntimeFiles(packDir) {
const files = [];
for (const dir of RUNTIME_DIRS) {
walkFiles(path.join(packDir, dir), files, dir);
}
for (const name of RUNTIME_ROOT_FILES) {
const full = path.join(packDir, name);
if (fs.existsSync(full)) {
files.push({ rel: name, full, size: fs.statSync(full).size });
}
}
return files.sort((a, b) => a.rel.localeCompare(b.rel));
}
function bundleNamesFromCatalog(catalogPath) {
if (!fs.existsSync(catalogPath)) return [];
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
return [...new Set(
(catalog.m_InternalIds || [])
.filter((id) => String(id).includes('.bundle'))
.map((id) => String(id).split('/').pop()),
)].sort();
}
function assertRuntimePack(packDir, opts) {
opts = opts || {};
const loader = path.join(packDir, 'Build/mstest5.loader.js');
const catalog = path.join(packDir, 'StreamingAssets/aa/catalog.json');
const levelsManifest = path.join(packDir, 'StreamingAssets/aa/levels-manifest.json');
if (!fs.existsSync(loader)) {
throw new Error(`缺少运行时包: ${loader}`);
}
if (!fs.existsSync(catalog)) {
throw new Error(`缺少运行时包: ${catalog}`);
}
if (!fs.existsSync(path.join(packDir, 'levels-database.json'))) {
throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-database.json')}`);
}
const names = 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) {
const manifest = JSON.parse(fs.readFileSync(levelsManifest, 'utf8'));
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}`);
}
}
}
module.exports = {
RUNTIME_ROOT_FILES,
RUNTIME_DIRS,
walkFiles,
listRuntimeFiles,
bundleNamesFromCatalog,
assertRuntimePack,
};

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* 将 build/assets/level-prefabs 拆为:
* - shell: config.json + index.js进 assets_all 首屏)
* - 每关一包: assets/level-prefabs/import/.../*.json
*
* 输出 levels-manifest.json 供 loader 按 levelId 按需下载。
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { formatBytes } = require('./package-optimize');
function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
function hashFileMd5(filePath) {
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
}
function zipDir(srcDir, outFile) {
const absOut = path.resolve(outFile);
mkdirp(path.dirname(absOut));
if (fs.existsSync(absOut)) fs.unlinkSync(absOut);
execSync(`cd "${path.resolve(srcDir)}" && zip -0 -q -r "${absOut}" .`, { stdio: 'pipe' });
}
function walkJsonFiles(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) walkJsonFiles(p, out);
else if (ent.name.endsWith('.json')) out.push(p);
}
return out;
}
/** 扫描 import/*.json从预制体名 Level{id} 建立 levelId → 相对路径 */
function indexImportFiles(levelPrefabsDir) {
const importRoot = path.join(levelPrefabsDir, 'import');
const byLevelId = new Map();
for (const abs of walkJsonFiles(importRoot)) {
const rel = path.relative(levelPrefabsDir, abs).split(path.sep).join('/');
const txt = fs.readFileSync(abs, 'utf8');
const m = /"Level(\d+)"/.exec(txt);
if (!m) continue;
const levelId = m[1];
if (byLevelId.has(levelId)) {
console.warn(`>>> 警告: Level${levelId} 重复 import保留 ${byLevelId.get(levelId)}`);
continue;
}
byLevelId.set(levelId, rel);
}
return byLevelId;
}
/** 从 config.json 校验 path ↔ uuid */
function readConfigLevels(levelPrefabsDir) {
const cfg = JSON.parse(fs.readFileSync(path.join(levelPrefabsDir, 'config.json'), 'utf8'));
const out = new Map();
for (const [idx, entry] of Object.entries(cfg.paths || {})) {
const m = /^level-prefabs\/Level(\d+)$/.exec(entry[0]);
if (!m) continue;
out.set(m[1], {
path: entry[0],
pathIndex: idx,
uuid: (cfg.uuids || [])[+idx],
});
}
return out;
}
/**
* @param {string} levelPrefabsDir Cocos 构建产物 assets/level-prefabs
* @param {string} webglDir 输出 WebGL/*.bundle
* @param {string} manifestOutPath 输出 levels-manifest.json
*/
function splitLevelBundles(levelPrefabsDir, webglDir, manifestOutPath, packTmpDir) {
if (!fs.existsSync(path.join(levelPrefabsDir, 'config.json'))) {
throw new Error(`缺少 level-prefabs/config.json: ${levelPrefabsDir}`);
}
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 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),
);
const zipTmp = path.join(stageBase, `${levelId}.zip`);
zipDir(stageRoot, zipTmp);
const hash = hashFileMd5(zipTmp);
const bundleName = `defaultlocalgroup_level_${levelId}_${hash}.bundle`;
fs.copyFileSync(zipTmp, path.join(webglDir, bundleName));
const size = fs.statSync(zipTmp).size;
totalBytes += size;
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);
}
fs.rmSync(stageBase, { recursive: true, force: true });
fs.writeFileSync(manifestOutPath, JSON.stringify(manifest), 'utf8');
console.log(`>>> 关卡分包: ${packed} 关, 合计 ${formatBytes(totalBytes)}, 均 ${formatBytes(Math.round(totalBytes / Math.max(packed, 1)))}/关`);
if (missing.length) {
console.warn(`>>> 警告: ${missing.length} 关在 config 中无 import 文件 (例: ${missing.slice(0, 5).join(', ')})`);
}
return { manifest, packed, missing, totalBytes };
}
module.exports = {
splitLevelBundles,
indexImportFiles,
readConfigLevels,
};
if (require.main === module) {
const levelDir = path.resolve(process.argv[2]);
const webgl = path.resolve(process.argv[3]);
const manifest = path.resolve(process.argv[4] || path.join(path.dirname(webgl), 'levels-manifest.json'));
splitLevelBundles(levelDir, webgl, manifest);
}

31
tools/sync-level-db.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# 从 Cocos 工程导出 levels-database.json权威数据源
#
# bash tools/sync-level-db.sh
#
# 地图/主题level-prefabs/Level*.prefab → LevelMapData
# spawns保留当前 levels-database.json 中由关卡编辑器维护的条目
# Unity 主站不参与此流程(仅作 ID 对照参考时用 sync-reference-from-unity.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/assets/level-data/levels-database.json"
PREFAB_LEGACY="$ROOT/assets/resources/level-prefabs"
PREFAB_BUNDLE="$ROOT/assets/bundle-level-prefabs/level-prefabs"
if [[ -d "$PREFAB_BUNDLE" ]]; then
PREFAB_SRC="$PREFAB_BUNDLE"
else
PREFAB_SRC="$PREFAB_LEGACY"
fi
echo "==> 从 Cocos 导出关卡库"
echo " 预制体: $PREFAB_SRC"
echo " 输出: $OUT"
python3 "$ROOT/tools/export_cocos_level_db.py" \
--project "$ROOT" \
--output "$OUT"
echo "==> 完成。请在 Cocos Creator 中重新构建 Web再 bash tools/package-for-project.sh && import-to-unity.sh"
echo " 关卡库: assets/level-data/levels-database.json不进 resources bundle"

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 已弃用:请用 sync-level-db.shCocos 权威)
# 本脚本转调 Cocos 导出,避免误从 Unity 覆盖地图数据。
exec "$(cd "$(dirname "$0")" && pwd)/sync-level-db.sh" "$@"

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 【可选 · 仅参考】从 Unity 主站导入 spawns / boundary 到 Cocos levels-database.json
#
# 正常运行包不依赖 Unity。仅在需要对照 Unity Levels*.cs 批量补齐 spawn 时使用。
# 地图与贴图仍以 Cocos 预制体为准;本脚本默认不解析 Unity prefab 瓦片。
#
# bash tools/sync-reference-from-unity.sh
# bash tools/sync-reference-from-unity.sh "/path/to/主站"
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
UNITY_ROOT="${1:-$HOME/tfrh/主站文件/主站}"
TMP="$ROOT/assets/resources/level/.unity-reference-import.json"
OUT="$ROOT/assets/level-data/levels-database.json"
[[ -d "$UNITY_ROOT/Assets/Scripts/Core" ]] || {
echo "错误: Unity 参考目录无效: $UNITY_ROOT" >&2
exit 1
}
echo "==> [参考] 从 Unity 导入 spawns不覆盖 Cocos 预制体地图)"
python3 "$ROOT/tools/export_all_levels.py" \
--unity-root "$UNITY_ROOT" \
--output "$TMP" \
--skip-prefab-maps
python3 << PY
import json
from pathlib import Path
out = Path("$OUT")
ref = Path("$TMP")
db = json.loads(out.read_text(encoding="utf-8")) if out.is_file() else {"levels": {}}
unity = json.loads(ref.read_text(encoding="utf-8"))
merged = 0
for key, u in (unity.get("levels") or {}).items():
cur = db.setdefault("levels", {}).setdefault(key, {})
if u.get("spawns"):
cur["spawns"] = u["spawns"]
merged += 1
if u.get("boundary"):
cur["boundary"] = u["boundary"]
if u.get("unityPrefab"):
cur["unityPrefab"] = u["unityPrefab"]
cur.setdefault("levelID", u.get("levelID", int(key)))
cur.setdefault("cocosPrefab", u.get("cocosPrefab", f"level-prefabs/Level{key}"))
db["source"] = (db.get("source") or "") + " + unity-reference-spawns"
out.write_text(json.dumps(db, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"merged spawns for {merged} levels into {out}")
PY
rm -f "$TMP"
echo "==> 建议再运行: bash tools/sync-level-db.sh (用 Cocos 预制体刷新地图/主题)"

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""从 Unity 源按 sanxing 命名规范完整同步主题贴图。"""
from __future__ import annotations
import importlib.util
import shutil
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TEX = ROOT / "assets" / "resources" / "textures"
UNITY = Path("/Users/liuyufei/tfrh/竞赛/scratch-unity-base/Assets/Texture")
_spec = importlib.util.spec_from_file_location(
"unify", ROOT / "tools" / "unify-theme-texture-names.py"
)
unify = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(unify)
RENAMES = unify.RENAMES
THEME_SHIP = unify.THEME_SHIP
def unity_src(theme: str, rel: str) -> Path | None:
rel = rel.replace(".png", "")
def with_png(base: Path) -> Path | None:
fp = base.with_suffix(".png")
return fp if fp.exists() else None
if theme == "chinese":
if rel.startswith("skin/"):
hit = with_png(UNITY / "Panda" / rel[len("skin/"):])
if hit:
return hit
hit = with_png(UNITY / "Chinese" / rel)
if hit:
return hit
return with_png(TEX / "chinese" / "_legacy" / rel)
if theme == "redArmy":
rel_u = rel.replace("redArmyShip", "redarmyship")
return with_png(UNITY / "redarmy" / rel_u)
p = UNITY / theme / rel
if theme == "silu" and not p.with_suffix(".png").exists() and rel.startswith("skin/跳背面/"):
name = Path(rel).name
alt_name = "机器人" if name == "1" else f"机器人{name}"
hit = with_png(UNITY / "silu" / "player" / "jump-正" / alt_name)
if hit:
return hit
return with_png(p)
def cocos_dst(theme: str, rel: str) -> Path:
return (TEX / theme / rel.replace(".png", "")).with_suffix(".png")
def sync_theme(theme: str) -> int:
copied = 0
for t, old_rel, new_rel in RENAMES:
if t != theme:
continue
src = unity_src(theme, old_rel)
if not src:
continue
dst = cocos_dst(theme, new_rel)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
copied += 1
# 已有 sanxing 命名的根目录文件Unity 与 sanxing 同名)
if theme == "numMan":
src = UNITY / "numMan" / "nProp_kuai .png"
if src.exists():
shutil.copy2(src, TEX / "numMan" / "nProp_kuai1.png")
copied += 1
for name in [
"Baseblock", "JumpBlock", "WallBlock", "kuai11", "bg",
"anniu_03", "anniu_06", "anniu_08", "anniu_10", "anniu_12",
"anniu_17", "anniu_19", "anniu_21", "anniu_22",
]:
src = UNITY / "numMan" / f"{name}.png"
if src.exists():
shutil.copy2(src, TEX / "numMan" / f"{name}.png")
copied += 1
sf, sb = THEME_SHIP["numMan"]
for name in (sf, sb):
src = UNITY / "numMan" / f"{name}.png"
if src.exists():
shutil.copy2(src, TEX / "numMan" / f"{name}.png")
copied += 1
if theme == "redArmy":
for name in [
"Baseblock", "JumpBlock", "WallBlock", "bg",
]:
src = UNITY / "redarmy" / f"{name}.png"
if src.exists():
shutil.copy2(src, TEX / "redArmy" / f"{name}.png")
copied += 1
if theme == "snow":
for old, new in [("Prop_kuai2", "Prop_kuai1"), ("nProp_kuai2", "nProp_kuai1")]:
for base in [TEX / "snow", TEX / "snow" / "_legacy", TEX / "_orphan_stages" / "snow"]:
src = base / f"{old}.png"
if src.exists():
shutil.copy2(src, TEX / "snow" / f"{new}.png")
copied += 1
break
return copied
def main() -> None:
total = 0
for theme in ["silu", "numMan", "redArmy", "chinese", "snow"]:
n = sync_theme(theme)
print(f"{theme}: copied {n}")
total += n
unify.patch_themes_database()
unify.patch_palettes()
print(f"done, {total} files")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 将运行时包同步到 scratch-gui/static/unity/
# 与 OSS unitycdndir 内容完全一致Build/ + StreamingAssets/ + levels-database.*
#
# bash tools/sync-unity-package-to-static.sh <packDir> <staticUnityDir>
set -euo pipefail
PACK_DIR="$(cd "${1:?packDir}" && pwd)"
UNITY_STATIC="$(cd "${2:?staticUnityDir}" && pwd)"
DRY_RUN="${DRY_RUN:-0}"
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
exit 1
fi
mkdir -p "$UNITY_STATIC/Build" "$UNITY_STATIC/StreamingAssets"
RSYNC_FLAGS=(-a --delete)
[[ "$DRY_RUN" -eq 1 ]] && RSYNC_FLAGS=(-a -n -v)
echo "==> 同步运行时包 → static/unity与 OSS unitycdndir 相同)"
echo " 源: $PACK_DIR"
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
if [[ -f "$PACK_DIR/$db" ]]; then
if [[ "$DRY_RUN" -eq 1 ]]; then
echo " [dry-run] cp $PACK_DIR/$db$UNITY_STATIC/"
else
cp "$PACK_DIR/$db" "$UNITY_STATIC/$db"
echo " copied $db"
fi
fi
done
if [[ "$DRY_RUN" -eq 0 ]]; then
for legacy in index.js application.js cocos-bridge.js assets cocos-js src index.html TemplateData; do
if [[ -e "$UNITY_STATIC/$legacy" ]]; then
rm -rf "$UNITY_STATIC/$legacy"
echo " removed legacy: $legacy"
fi
done
fi
echo "==> 完成。usecdn:false → /unity/ usecdn:true → unitycdndir文件相同"

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""为 themes-database.json 各主题写入 propGroundnProp路径及 entityDisplay 默认值。"""
from __future__ import annotations
import json
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
DB_PATH = PROJECT / "assets/resources/theme/themes-database.json"
# 各主题 prop → nProp 贴图resources 相对路径,无 .png
PROP_GROUND: dict[str, str] = {
"silu": "textures/silu/nProp_kuai1",
"sanxing": "textures/sanxing/nProp_kuai1",
"snow": "textures/snow/nProp_kuai2",
"chinese": "textures/chinese/nProp_Dumpling",
"numMan": "textures/numMan/nProp_kuai",
"redarmy": "textures/redArmy/nProp_star",
}
DEFAULT_BLOCK_Y = 14
DEFAULT_GROUND_Y = -11
DEFAULT_MOVER_EMPTY_Y = 25
def main() -> None:
db = json.loads(DB_PATH.read_text(encoding="utf-8"))
for theme_id, ground_path in PROP_GROUND.items():
theme = db.get("themes", {}).get(theme_id)
if not theme:
print(f"skip missing theme: {theme_id}")
continue
theme.setdefault("entities", {})["propGround"] = ground_path
ed = theme.setdefault("entityDisplay", {})
prop_scale = ed.get("prop", {}).get("scale", 1)
ed.setdefault("propGround", {"scale": prop_scale})
ed.setdefault("propBlockYOffset", DEFAULT_BLOCK_Y)
ed.setdefault("propGroundYOffset", DEFAULT_GROUND_Y)
ed.setdefault("moverEmptyCellYOffset", DEFAULT_MOVER_EMPTY_Y)
png = PROJECT / "assets/resources" / f"{ground_path}.png"
status = "ok" if png.is_file() else "MISSING"
print(f" {theme_id}: propGround={ground_path} [{status}]")
DB_PATH.write_text(json.dumps(db, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Updated {DB_PATH}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,453 @@
#!/usr/bin/env python3
"""将各主题 textures 文件名统一为与 sanxing 相同的命名规范。"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TEX = ROOT / "assets" / "resources" / "textures"
RENAMES: list[tuple[str, str, str]] = []
def add(theme: str, old: str, new: str) -> None:
old = old.replace("\\", "/")
new = new.replace("\\", "/")
if not old.endswith(".png"):
old += ".png"
if not new.endswith(".png"):
new += ".png"
RENAMES.append((theme, old, new))
# ── silu ──
for src, dst in [
("素材切图_画板 1", "bg"),
("素材切图-03", "anniu_03"),
("素材切图-05", "anniu_06"),
("1倍速", "anniu_08"),
("2倍速", "anniu_10"),
("4倍速", "anniu_12"),
("素材切图-09", "anniu_17"),
("素材切图-10", "anniu_19"),
("声音", "anniu_22"),
("声音关闭", "anniu_21"),
("素材切图-23", "kuai11"),
("Decor23", "kuai11"),
]:
add("silu", src, dst)
for i, src in enumerate(["机器人", "机器人2", "机器人3", "机器人4"], start=1):
add("silu", f"skin/跳背面/{src}", f"skin/跳背面/{i}")
# ── snow ──
add("snow", "Prop_kuai2", "Prop_kuai1")
add("snow", "nProp_kuai2", "nProp_kuai1")
# ── numMan ──
for i in range(1, 5):
add("numMan", f"skin/待机/待机{i}", f"skin/待机正面/{i}")
for i in range(1, 3):
add("numMan", f"skin/待机背面/待机{i}", f"skin/待机背面/{i}")
for i in range(1, 5):
add("numMan", f"skin/走/走{i}", f"skin/走/{i}")
add("numMan", f"skin/走背面/走背面{i}", f"skin/走背面/{i}")
add("numMan", "skin/跳/跳1", "skin/跳/1")
add("numMan", "skin/跳/跳2", "skin/跳/2")
add("numMan", "skin/跳/跳4", "skin/跳/3")
add("numMan", "skin/跳/跳5", "skin/跳/4")
add("numMan", "skin/跳背面/跳背面1", "skin/跳背面/1")
add("numMan", "skin/跳背面/跳背面2", "skin/跳背面/2")
add("numMan", "skin/跳背面/跳背面4", "skin/跳背面/3")
add("numMan", "skin/跳背面/跳背面5", "skin/跳背面/4")
add("numMan", "Prop_kuai", "Prop_kuai1")
add("numMan", "nProp_kuai", "nProp_kuai1")
# ── redArmy ──
for src, dst in [
("小游戏素材红色_03", "kuai11"),
("redarmy02", "anniu_03"),
("redarmy09", "anniu_06"),
("redarmy11", "anniu_08"),
("redarmy13", "anniu_10"),
("redarmy15", "anniu_12"),
("redarmy20", "anniu_17"),
("redarmy29", "anniu_19"),
("redarmy35", "anniu_22"),
("redarmy36", "anniu_21"),
("Prop_star", "Prop_kuai1"),
("nProp_star", "nProp_kuai1"),
("redarmyship_F", "redArmyShip_F"),
("redarmyship_B", "redArmyShip_B"),
]:
add("redArmy", src, dst)
for i in range(1, 3):
add("redArmy", f"skin/待机/小红军待机{i}", f"skin/待机正面/{i}")
add("redArmy", f"skin/待机背面/小红军待机{i}", f"skin/待机背面/{i}")
for i in range(1, 4):
add("redArmy", f"skin/走/小红军走{i}", f"skin/走/{i}")
add("redArmy", f"skin/走背面/小红军走{i}", f"skin/走背面/{i}")
add("redArmy", f"skin/跳/小红军跳{i}", f"skin/跳/{i}")
add("redArmy", f"skin/跳背面/小红军跳背面{i}", f"skin/跳背面/{i}")
# ── chinese ──
for src, dst in [
("素材切图2_画板 1", "bg"),
("素材切图2-23", "kuai11"),
("素材切图2-02", "anniu_03"),
("素材切图2-03", "anniu_06"),
("素材切图2-06", "anniu_08"),
("素材切图2-07", "anniu_10"),
("素材切图2-08", "anniu_12"),
("素材切图2-09", "anniu_17"),
("素材切图2-10", "anniu_19"),
("素材切图2-11", "anniu_22"),
("素材切图2-12", "anniu_21"),
("Prop_Dumpling", "Prop_kuai1"),
("nProp_Dumpling", "nProp_kuai1"),
("yun_F", "chineseShip_F"),
("yun_B", "chineseShip_B"),
]:
add("chinese", src, dst)
add("chinese", "skin/待机/机器人", "skin/待机正面/1")
add("chinese", "skin/待机/机器人-1", "skin/待机正面/2")
add("chinese", "skin/待机背面/机器人", "skin/待机背面/1")
add("chinese", "skin/待机背面/机器人-1", "skin/待机背面/2")
add("chinese", "skin/走/机器人", "skin/走/1")
add("chinese", "skin/走/机器人-1", "skin/走/2")
add("chinese", "skin/走/机器人-2", "skin/走/3")
add("chinese", "skin/走背面/机器人", "skin/走背面/1")
add("chinese", "skin/走背面/机器人-1", "skin/走背面/2")
add("chinese", "skin/走背面/机器人-2", "skin/走背面/3")
add("chinese", "skin/跳/机器人", "skin/跳/1")
add("chinese", "skin/跳/机器人-1", "skin/跳/2")
add("chinese", "skin/跳/机器人-2", "skin/跳/3")
add("chinese", "skin/跳背面/机器人", "skin/跳背面/1")
add("chinese", "skin/跳背面/机器人-1", "skin/跳背面/2")
add("chinese", "skin/跳背面/机器人-2", "skin/跳背面/3")
def _norm_rel(p: str) -> str:
p = p.replace("\\", "/")
return p[:-4] if p.endswith(".png") else p
def build_rename_lookup() -> dict[tuple[str, str], str]:
lookup: dict[tuple[str, str], str] = {}
for theme, old, new in RENAMES:
old_n = _norm_rel(old)
new_n = _norm_rel(new)
lookup[(theme, Path(old_n).name)] = new_n
lookup[(theme, old_n)] = new_n
return lookup
def move_pair(src: Path, dst: Path, *, skip_if_dst_exists: bool = False) -> bool:
if not src.exists():
return False
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
if skip_if_dst_exists:
legacy = dst.parent / "_legacy" / "duplicates" / src.name
legacy.parent.mkdir(parents=True, exist_ok=True)
return move_pair(src, legacy)
return False
os.rename(src, dst)
meta_src = Path(str(src) + ".meta")
meta_dst = Path(str(dst) + ".meta")
if meta_src.exists():
if meta_dst.exists():
meta_dst.unlink()
os.rename(meta_src, meta_dst)
return True
def read_meta_display_name(meta_path: Path) -> str | None:
if not meta_path.exists():
return None
try:
data = json.loads(meta_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
for sub in (data.get("subMetas") or {}).values():
name = sub.get("displayName")
if name:
return str(name)
return None
def recover_stages_from_meta() -> int:
lookup = build_rename_lookup()
recovered = 0
for stage in sorted(TEX.rglob("__stage_*.png")):
theme_key = stage.relative_to(TEX).parts[0]
display = read_meta_display_name(Path(str(stage) + ".meta"))
if not display:
continue
new_rel = lookup.get((theme_key, display)) or lookup.get((theme_key, _norm_rel(display)))
if not new_rel:
continue
dst = TEX / theme_key / f"{new_rel}.png"
if move_pair(stage, dst, skip_if_dst_exists=True):
recovered += 1
print(f" recovered {theme_key}/{display} -> {new_rel}.png")
return recovered
def apply_renames() -> int:
count = 0
for theme, old_rel, new_rel in RENAMES:
src = TEX / theme / old_rel
dst = TEX / theme / new_rel
if not src.exists():
continue
if dst.exists():
move_pair(src, src.parent / "_legacy" / "duplicates" / src.name)
continue
if move_pair(src, dst):
count += 1
return count
def cleanup_empty_dirs(theme_dir: Path) -> None:
for dirpath, dirnames, filenames in os.walk(theme_dir, topdown=False):
p = Path(dirpath)
if p == theme_dir:
continue
has_png = any(f.endswith(".png") for f in filenames)
if not has_png and not dirnames:
for f in list(p.iterdir()):
f.unlink()
meta = Path(str(p) + ".meta")
if meta.exists():
meta.unlink()
p.rmdir()
SANXING_KEEP = {
"bg", "Baseblock", "JumpBlock", "WallBlock", "kuai11",
"Prop_kuai1", "nProp_kuai1",
"anniu_03", "anniu_06", "anniu_08", "anniu_10", "anniu_12",
"anniu_17", "anniu_19", "anniu_21", "anniu_22",
}
THEME_SHIP = {
"silu": ("siluShip_F", "siluShip_B"),
"snow": ("snowShip_F", "snowShip_B"),
"numMan": ("numManShip_F", "numManShip_B"),
"redArmy": ("redArmyShip_F", "redArmyShip_B"),
"chinese": ("chineseShip_F", "chineseShip_B"),
"sanxing": ("sanxingShip_F", "sanxingShip_B"),
}
def move_legacy(theme: str, keep_names: set[str]) -> None:
theme_dir = TEX / theme
legacy = theme_dir / "_legacy"
for path in sorted(theme_dir.rglob("*.png")):
rel = path.relative_to(theme_dir).as_posix()
if rel.startswith("_legacy/") or rel.startswith("__stage_"):
continue
base = rel.replace(".png", "")
if base in keep_names:
continue
if base.startswith("skin/"):
parts = base.split("/")
if len(parts) == 3 and parts[1] in {
"待机正面", "待机背面", "", "走背面", "", "跳背面",
} and re.fullmatch(r"\d+", parts[2]):
continue
dest = legacy / rel
dest.parent.mkdir(parents=True, exist_ok=True)
move_pair(path, dest)
def build_path_replacements() -> dict[str, str]:
pairs = [
("textures/silu/素材切图_画板 1", "textures/silu/bg"),
("textures/silu/素材切图-23", "textures/silu/kuai11"),
("textures/silu/Decor23", "textures/silu/kuai11"),
("textures/silu/素材切图-03", "textures/silu/anniu_03"),
("textures/silu/素材切图-05", "textures/silu/anniu_06"),
("textures/silu/1倍速", "textures/silu/anniu_08"),
("textures/silu/2倍速", "textures/silu/anniu_10"),
("textures/silu/4倍速", "textures/silu/anniu_12"),
("textures/silu/素材切图-09", "textures/silu/anniu_17"),
("textures/silu/素材切图-10", "textures/silu/anniu_19"),
("textures/silu/声音关闭", "textures/silu/anniu_21"),
("textures/silu/声音", "textures/silu/anniu_22"),
("textures/snow/Prop_kuai2", "textures/snow/Prop_kuai1"),
("textures/snow/nProp_kuai2", "textures/snow/nProp_kuai1"),
("textures/numMan/Prop_kuai", "textures/numMan/Prop_kuai1"),
("textures/numMan/nProp_kuai", "textures/numMan/nProp_kuai1"),
("textures/numMan/skin/待机/待机1", "textures/numMan/skin/待机正面/1"),
("textures/numMan/skin/待机背面/待机1", "textures/numMan/skin/待机背面/1"),
("textures/redArmy/小游戏素材红色_03", "textures/redArmy/kuai11"),
("textures/redArmy/Prop_star", "textures/redArmy/Prop_kuai1"),
("textures/redArmy/nProp_star", "textures/redArmy/nProp_kuai1"),
("textures/redArmy/redarmyship_F", "textures/redArmy/redArmyShip_F"),
("textures/redArmy/redarmyship_B", "textures/redArmy/redArmyShip_B"),
("textures/redArmy/redarmy02", "textures/redArmy/anniu_03"),
("textures/redArmy/redarmy09", "textures/redArmy/anniu_06"),
("textures/redArmy/redarmy11", "textures/redArmy/anniu_08"),
("textures/redArmy/redarmy13", "textures/redArmy/anniu_10"),
("textures/redArmy/redarmy15", "textures/redArmy/anniu_12"),
("textures/redArmy/redarmy20", "textures/redArmy/anniu_17"),
("textures/redArmy/redarmy29", "textures/redArmy/anniu_19"),
("textures/redArmy/redarmy35", "textures/redArmy/anniu_22"),
("textures/redArmy/redarmy36", "textures/redArmy/anniu_21"),
("textures/redArmy/skin/待机/小红军待机1", "textures/redArmy/skin/待机正面/1"),
("textures/redArmy/skin/待机背面/小红军待机1", "textures/redArmy/skin/待机背面/1"),
("textures/chinese/素材切图2_画板 1", "textures/chinese/bg"),
("textures/chinese/素材切图2-23", "textures/chinese/kuai11"),
("textures/chinese/素材切图2-02", "textures/chinese/anniu_03"),
("textures/chinese/素材切图2-03", "textures/chinese/anniu_06"),
("textures/chinese/素材切图2-06", "textures/chinese/anniu_08"),
("textures/chinese/素材切图2-07", "textures/chinese/anniu_10"),
("textures/chinese/素材切图2-08", "textures/chinese/anniu_12"),
("textures/chinese/素材切图2-09", "textures/chinese/anniu_17"),
("textures/chinese/素材切图2-10", "textures/chinese/anniu_19"),
("textures/chinese/素材切图2-11", "textures/chinese/anniu_22"),
("textures/chinese/素材切图2-12", "textures/chinese/anniu_21"),
("textures/chinese/Prop_Dumpling", "textures/chinese/Prop_kuai1"),
("textures/chinese/nProp_Dumpling", "textures/chinese/nProp_kuai1"),
("textures/chinese/yun_F", "textures/chinese/chineseShip_F"),
("textures/chinese/yun_B", "textures/chinese/chineseShip_B"),
("textures/chinese/Panda/待机/机器人", "textures/chinese/skin/待机正面/1"),
("textures/chinese/Panda/待机背面/机器人", "textures/chinese/skin/待机背面/1"),
("textures/chinese/Panda/走", "textures/chinese/skin/走"),
("textures/chinese/Panda/跳", "textures/chinese/skin/跳"),
("textures/chinese/Panda/走背面", "textures/chinese/skin/走背面"),
("textures/chinese/Panda/跳背面", "textures/chinese/skin/跳背面"),
("textures/chinese/Panda/待机", "textures/chinese/skin/待机正面"),
('borderDecorKey": "素材切图-23', 'borderDecorKey": "kuai11'),
('borderDecorKey": "素材切图2-23', 'borderDecorKey": "kuai11'),
('borderDecorKey": "小游戏素材红色_03', 'borderDecorKey": "kuai11'),
]
return dict(pairs)
def patch_text_files(reps: dict[str, str]) -> int:
count = 0
for base in [ROOT / "assets", ROOT / "extensions"]:
if not base.exists():
continue
for path in base.rglob("*"):
if path.suffix not in {".ts", ".json", ".js", ".jsx"}:
continue
if "node_modules" in path.parts or "library" in path.parts:
continue
text = path.read_text(encoding="utf-8")
orig = text
for old, new in sorted(reps.items(), key=lambda x: -len(x[0])):
text = text.replace(old, new)
if text != orig:
path.write_text(text, encoding="utf-8")
count += 1
print(f" patched {path.relative_to(ROOT)}")
return count
def patch_themes_database() -> None:
db_path = ROOT / "assets" / "resources" / "theme" / "themes-database.json"
data = json.loads(db_path.read_text(encoding="utf-8"))
themes = data["themes"]
def hud(theme: str) -> dict:
return {
"navigation": f"textures/{theme}/anniu_03",
"revert": f"textures/{theme}/anniu_06",
"speed1": f"textures/{theme}/anniu_08",
"speed2": f"textures/{theme}/anniu_10",
"speed4": f"textures/{theme}/anniu_12",
"zoomIn": f"textures/{theme}/anniu_17",
"zoomOut": f"textures/{theme}/anniu_19",
"audioOn": f"textures/{theme}/anniu_22",
"audioOff": f"textures/{theme}/anniu_21",
}
folder_map = {
"silu": "silu", "sanxing": "sanxing", "snow": "snow",
"chinese": "chinese", "numMan": "numMan", "redarmy": "redArmy",
}
for key, folder in folder_map.items():
if key not in themes:
continue
t = themes[key]
ship_f, ship_b = THEME_SHIP[folder]
t["background"] = f"textures/{folder}/bg"
t["borderDecorKey"] = "kuai11"
t["entities"] = {
"playerFront": f"textures/{folder}/skin/待机正面/1",
"playerBack": f"textures/{folder}/skin/待机背面/1",
"vehicleFront": f"textures/{folder}/{ship_f}",
"vehicleBack": f"textures/{folder}/{ship_b}",
"prop": f"textures/{folder}/Prop_kuai1",
"propGround": f"textures/{folder}/nProp_kuai1",
}
t["tiles"] = {
"Baseblock": f"textures/{folder}/Baseblock",
"JumpBlock": f"textures/{folder}/JumpBlock",
"WallBlock": f"textures/{folder}/WallBlock",
"borderDecor": f"textures/{folder}/kuai11",
}
existing_hud = t.get("hud", {})
t["hud"] = {**hud(folder), **{k: v for k, v in existing_hud.items() if k not in hud(folder)}}
db_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f" updated {db_path.relative_to(ROOT)}")
def patch_palettes() -> None:
idx = ROOT / "assets" / "resources" / "map-tiles" / "palettes" / "_index.json"
if idx.exists():
data = json.loads(idx.read_text(encoding="utf-8"))
for pal in data.get("palettes", []):
theme = pal.get("theme") or pal.get("id", "")
folder = {"redarmy": "redArmy"}.get(theme, theme)
for tile in pal.get("tiles", []):
tex = tile.get("texture", "")
if any(x in tex for x in ("素材", "Decor", "小游戏", "kuai11")):
tile["texture"] = f"textures/{folder}/kuai11"
idx.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
for name, folder in [("silu", "silu"), ("chinese", "chinese"), ("redarmy", "redArmy")]:
p = ROOT / "assets" / "resources" / "map-tiles" / "palettes" / f"{name}.json"
if not p.exists():
continue
pal = json.loads(p.read_text(encoding="utf-8"))
for tile in pal.get("tiles", []):
tex = tile.get("texture", "")
if any(x in tex for x in ("素材", "Decor", "小游戏", "kuai11")):
tile["texture"] = f"textures/{folder}/kuai11"
p.write_text(json.dumps(pal, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> None:
print("=== 恢复 __stage__ 贴图 ===")
n_rec = recover_stages_from_meta()
print(f" 恢复 {n_rec} 个文件")
print("=== 重命名贴图 ===")
n = apply_renames()
print(f" 完成 {n} 项重命名")
for theme in ["silu", "snow", "numMan", "redArmy", "chinese"]:
cleanup_empty_dirs(TEX / theme)
keep = set(SANXING_KEEP) | set(THEME_SHIP.get(theme, ()))
move_legacy(theme, keep)
print("=== 更新配置与代码引用 ===")
reps = build_path_replacements()
n_files = patch_text_files(reps)
patch_themes_database()
patch_palettes()
print(f" 共更新 {n_files} 个文本文件")
print("完成。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,53 @@
# Unity 关卡 Prefab 结构说明(以 Level3 为例)
## 两套数据源
| 来源 | 文件 | 内容 |
|------|------|------|
| 逻辑配置 | `Assets/Scripts/Core/Levels600.cs` | `spawns`(玩家/道具坐标)、`boundary``levelPath` |
| 地图视觉 | `Assets/Prefabs/Level/Level3.prefab` | `Ground` / `Border` 两层 **Tilemap** |
运行时 `GameManager.CreateNewLevel`
1. Addressables 加载 `levelPath` → 实例化 `Level3` 预制体(含 Grid + Tilemap
2. 再按 `spawns` 实例化 `Player.prefab``Prop.prefab` 等到关卡根节点
**玩家/道具不在 Prefab 里**,只在 `Levels*.cs``spawns` 中定义。
## Level3.prefab 层级
```
Level3
├── Grid # cellSize (1, 0.5),等距/菱形格
├── Border # Tilemap — 墙/障碍 → GridType.Block
└── Ground # Tilemap — 可走地块 → GridType.Across / Jump
```
### 第 3 关格子(从 Prefab 解析)
- **Ground8 格)**`(-3,0)…(4,0)` 一字横排,`Baseblock` → 可走 `Across`
- **Border22 格)**:底部一排 + 左右墙 + 顶部墙,围成通道
`Levels600.cs` 中 spawns 一致:
- Player `(0,0)` North
- Prop `(-3,0)``(4,0)` 在通道两端
## Cocos 侧对应
`tools/export_unity_levels.py --prefab-dir` 会把 Prefab Tilemap 写入:
- `LevelConfig.ground`: `"x,y" → "Baseblock" | "JumpBlock"`
- `LevelConfig.border`: `"x,y" → true`
`GameManager` 按这些稀疏格子绘制地形并计算 `CalculateGridType`(与 Unity `Border.HasTile` / `Ground.GetTile` 一致)。
## 重新导出命令
```bash
python3 tools/export_unity_levels.py \
--input "/path/to/主站/Assets/Scripts/Core/Levels600.cs" \
--output assets/scripts/level/levels-20.generated.ts \
--prefab-dir "/path/to/主站/Assets/Prefabs/Level" \
--limit 20 --export-const LEVELS_20
```

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 检查「关卡分包迁移」与 Cocos 构建是否一致
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="${1:-$PROJECT_DIR/build/web-desktop}"
SRC_BUNDLE="$PROJECT_DIR/assets/bundle-level-prefabs/level-prefabs"
LEGACY="$PROJECT_DIR/assets/resources/level-prefabs"
BUILD_RESOURCES="$BUILD_DIR/assets/resources"
BUILD_LEVELS="$BUILD_DIR/assets/level-prefabs"
err=0
if [[ -d "$SRC_BUNDLE" ]]; then
echo "✓ 工程已迁移: bundle-level-prefabs/level-prefabs"
if [[ -d "$LEGACY" ]]; then
echo "✗ 仍存在旧目录 resources/level-prefabs请删除或重新运行 migrate-level-prefab-bundle.sh" >&2
err=1
fi
elif [[ -d "$LEGACY" ]]; then
echo "○ 尚未迁移关卡分包prefab 仍在 resources 内)"
echo " 运行: bash tools/migrate-level-prefab-bundle.sh"
exit 2
else
echo "✗ 未找到任何关卡预制体目录" >&2
exit 1
fi
[[ -f "$BUILD_DIR/index.js" ]] || {
echo "✗ 缺少 Cocos 构建: $BUILD_DIR" >&2
echo " 请在 Cocos Creator 中: 项目 → 构建 → Web Desktop → 构建" >&2
exit 1
}
if [[ -d "$BUILD_LEVELS" ]]; then
rs=$(du -sh "$BUILD_RESOURCES" 2>/dev/null | awk '{print $1}')
ls=$(du -sh "$BUILD_LEVELS" 2>/dev/null | awk '{print $1}')
echo "✓ 构建已含分包: assets/resources ($rs) + assets/level-prefabs ($ls)"
exit 0
fi
echo "✗ 构建未刷新(仍只有 resources无 assets/level-prefabs" >&2
if [[ -d "$BUILD_RESOURCES" ]]; then
du -sh "$BUILD_RESOURCES" 2>/dev/null || true
fi
echo "" >&2
echo " 迁移后必须重新构建 Web Desktop" >&2
echo " 1. 打开 Cocos Creator → 项目 tfrh001" >&2
echo " 2. 确认资源管理器中 bundle-level-prefabs 标记为 Asset Bundle「level-prefabs」" >&2
echo " 3. 菜单 项目 → 构建发布 → Web Desktop → 构建" >&2
exit 3

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* 为运行时包生成 OSS 上传清单(不复制文件 — 本地与 CDN 共用同一目录)。
*
* node tools/write-deploy-manifest.js [packDir] [--cdn-base URL] [--manifest-dir DIR] [--zip]
*
* 运行时包: build/mstest5/ ← 上传此目录全部内容到 unitycdndir
* 清单输出: build/deploy/UPLOAD-MANIFEST.txt不进 OSS
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { dirSize, formatBytes } = require('./package-optimize');
const { listRuntimeFiles, bundleNamesFromCatalog } = require('./runtime-pack');
const argv = process.argv.slice(2);
const flags = new Set(argv.filter((a) => a.startsWith('--')));
const positional = argv.filter((a) => !a.startsWith('--'));
const PROJECT = path.resolve(__dirname, '..');
const packDir = path.resolve(positional[0] || path.join(PROJECT, 'build/mstest5'));
const manifestDir = (() => {
const i = argv.indexOf('--manifest-dir');
return path.resolve(i >= 0 ? argv[i + 1] : path.join(PROJECT, 'build/deploy'));
})();
const cdnBase = (() => {
const i = argv.indexOf('--cdn-base');
return i >= 0 ? argv[i + 1] : '';
})();
const makeZip = flags.has('--zip');
if (!fs.existsSync(path.join(packDir, 'Build/mstest5.loader.js'))) {
console.error('错误: 无效运行时包,请先 bash tools/package-for-project.sh');
console.error(' 期望:', packDir);
process.exit(1);
}
const files = listRuntimeFiles(packDir);
const catalogPath = path.join(packDir, 'StreamingAssets/aa/catalog.json');
const bundleNames = bundleNamesFromCatalog(catalogPath);
const base = (cdnBase || 'https://YOUR_CDN/unitycdndir').replace(/\/$/, '');
fs.mkdirSync(manifestDir, { recursive: true });
const manifestPath = path.join(manifestDir, 'UPLOAD-MANIFEST.txt');
const readmePath = path.join(manifestDir, 'README.txt');
const lines = [
'# OSS 上传清单(自动生成)',
`# 生成时间: ${new Date().toISOString()}`,
`# 运行时包目录: ${packDir}`,
`# OSS 根路径 (unitycdndir): ${base}`,
'#',
'# 上传方式: 将「运行时包目录」内全部内容原样上传到 OSS',
'# 本地 static/unity 与 OSS 文件完全一致,前端仅 config.js usecdn 切换根 URL',
'#',
`# ossutil cp -r "${packDir}/" oss://BUCKET/PATH/`,
'',
'## 运行时包文件(本地 = CDN',
...files.map((f) => `- ${f.rel}`),
'',
'## OSS 目标路径',
...files.map((f) => `${base}/${f.rel}`),
'',
'## 上传后验证',
`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}"`),
];
fs.writeFileSync(manifestPath, lines.join('\n'), 'utf8');
const readme = [
'部署说明',
'========',
'',
`生成: ${new Date().toLocaleString('zh-CN')}`,
'',
'运行时包(本地与 OSS 相同):',
` ${packDir}/`,
' ├── Build/',
' ├── StreamingAssets/',
' └── levels-database.json(.br)',
'',
'本地: import-to-unity.sh → scratch-gui/static/unity/(同上结构)',
'CDN: 上传运行时包全部内容 → config.js unitycdndir + usecdn:true',
'',
`包体积: ${formatBytes(dirSize(packDir))}`,
`详细路径: ${manifestPath}`,
'',
'独立调试页(不进 OSS / static:',
` ${path.join(path.dirname(packDir), 'standalone-player')}/index.html`,
].join('\n');
fs.writeFileSync(readmePath, readme, 'utf8');
let zipPath = '';
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');
execSync(`cd "${packDir}" && zip -0 -q -r "${zipPath}" ${items.join(' ')}`, { stdio: 'pipe' });
}
console.log('>>> 运行时包:', packDir);
console.log(`>>> 文件数: ${files.length}, 体积: ${formatBytes(dirSize(packDir))}`);
console.log('>>> 上传清单:', manifestPath);
if (zipPath) {
console.log(`>>> ZIP: ${zipPath} (${formatBytes(fs.statSync(zipPath).size)})`);
}