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:
500
tools/bake_cocos_level_prefabs.py
Normal file
500
tools/bake_cocos_level_prefabs.py
Normal 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 子节点
|
||||
└── (挂 LevelMapData:ground/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()
|
||||
188
tools/bake_map_tile_prefabs.py
Normal file
188
tools/bake_map_tile_prefabs.py
Normal 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()
|
||||
124
tools/build_theme_palettes.py
Normal file
124
tools/build_theme_palettes.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 Unity Assets/Tile/{theme}.prefab + Assets/Texture/{theme} 生成 Cocos 调色板配置。
|
||||
对齐 Unity 平铺调色板(如 sanxing:WallBlock / 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()
|
||||
165
tools/build_theme_tile_meta.py
Normal file
165
tools/build_theme_tile_meta.py
Normal 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
62
tools/deploy-cdn.sh
Normal 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
72
tools/deploy-local.sh
Executable 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.html(Cmd+Shift+R)"
|
||||
echo ""
|
||||
echo "config.js: usecdn: false"
|
||||
246
tools/deploy-to-001code.sh
Executable file
246
tools/deploy-to-001code.sh
Executable 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(纯 CDN,loader 也走 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
189
tools/export_all_levels.py
Normal 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()
|
||||
198
tools/export_cocos_level_db.py
Normal file
198
tools/export_cocos_level_db.py
Normal file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 Cocos 工程导出 levels-database.json(权威数据源)。
|
||||
|
||||
地图 / 主题:level-prefabs/Level{id}.prefab 上的 LevelMapData(groundJson / 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()
|
||||
@@ -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*.cs;ground/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}")
|
||||
|
||||
|
||||
37
tools/export_unity_prefab_maps.py
Normal file
37
tools/export_unity_prefab_maps.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从 Unity Level{N}.prefab 解析 Tilemap(Ground / 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}
|
||||
280
tools/fix_tile_texture_metas.py
Normal file
280
tools/fix_tile_texture_metas.py
Normal 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()
|
||||
82
tools/generate-vehicle-direction-textures.py
Normal file
82
tools/generate-vehicle-direction-textures.py
Normal 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())
|
||||
356
tools/import_unity_textures.py
Normal file
356
tools/import_unity_textures.py
Normal 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
105
tools/level-map-editor.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 关卡地图编辑(对齐 Unity Tile Palette)
|
||||
|
||||
## 布局(与 Unity 一致)
|
||||
|
||||
| 区域 | Unity | Cocos |
|
||||
|------|-------|-------|
|
||||
| 中间 | `Level2` 预制体(Ground + Border Tilemap) | 网格画布 = 当前关卡布局;烘焙后见 `level-prefabs/LevelN.prefab` |
|
||||
| 右侧 | Tile Palette(Baseblock、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
|
||||
```
|
||||
2402
tools/level-prefab-index.json
Normal file
2402
tools/level-prefab-index.json
Normal file
File diff suppressed because it is too large
Load Diff
98
tools/level_id.py
Normal file
98
tools/level_id.py
Normal 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
79
tools/levels-database.md
Normal 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 中刷新资源并重新预览即可。
|
||||
71
tools/migrate-level-prefab-bundle.sh
Normal file
71
tools/migrate-level-prefab-bundle.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将关卡预制体从 resources 拆到独立 Asset Bundle(level-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"
|
||||
|
||||
# 子目录 meta(Creator 会在下次打开时补全 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"
|
||||
6
tools/package-cdn-upload.js
Normal file
6
tools/package-cdn-upload.js
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @deprecated 请用 write-deploy-manifest.js(不再复制 cdn-upload 目录)
|
||||
* 兼容旧脚本名,仅生成 build/deploy/ 清单。
|
||||
*/
|
||||
require('./write-deploy-manifest.js');
|
||||
70
tools/package-cdn-upload.sh
Normal file
70
tools/package-cdn-upload.sh
Normal 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
377
tools/package-for-cdn.js
Normal 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/竞赛/mstest5),static/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 bundle(Cocos 运行时 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
56
tools/package-for-cdn.sh
Normal 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"
|
||||
273
tools/package-for-project.js
Normal file
273
tools/package-for-project.js
Normal 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.json(Cocos 关卡库)');
|
||||
} 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
111
tools/package-for-project.sh
Executable 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
106
tools/package-optimize.js
Normal 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:默认只预加载 main,resources / 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,
|
||||
};
|
||||
102
tools/patch-theme-trim-hotfix.js
Normal file
102
tools/patch-theme-trim-hotfix.js
Normal 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);
|
||||
154
tools/recover-and-unify-textures.py
Normal file
154
tools/recover-and-unify-textures.py
Normal 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()
|
||||
142
tools/renumber-level-prefabs.py
Normal file
142
tools/renumber-level-prefabs.py
Normal 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
95
tools/runtime-pack.js
Normal 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,
|
||||
};
|
||||
155
tools/split-level-bundles.js
Normal file
155
tools/split-level-bundles.js
Normal 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
31
tools/sync-level-db.sh
Executable 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)"
|
||||
4
tools/sync-main-site-level-db.sh
Executable file
4
tools/sync-main-site-level-db.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 已弃用:请用 sync-level-db.sh(Cocos 权威)
|
||||
# 本脚本转调 Cocos 导出,避免误从 Unity 覆盖地图数据。
|
||||
exec "$(cd "$(dirname "$0")" && pwd)/sync-level-db.sh" "$@"
|
||||
53
tools/sync-reference-from-unity.sh
Executable file
53
tools/sync-reference-from-unity.sh
Executable 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 预制体刷新地图/主题)"
|
||||
120
tools/sync-themes-from-unity.py
Normal file
120
tools/sync-themes-from-unity.py
Normal 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()
|
||||
53
tools/sync-unity-package-to-static.sh
Executable file
53
tools/sync-unity-package-to-static.sh
Executable 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(文件相同)"
|
||||
48
tools/sync_theme_nprop_ground.py
Normal file
48
tools/sync_theme_nprop_ground.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 themes-database.json 各主题写入 propGround(nProp)路径及 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()
|
||||
453
tools/unify-theme-texture-names.py
Normal file
453
tools/unify-theme-texture-names.py
Normal 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()
|
||||
53
tools/unity-level-prefab.md
Normal file
53
tools/unity-level-prefab.md
Normal 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 解析)
|
||||
|
||||
- **Ground(8 格)**:`(-3,0)…(4,0)` 一字横排,`Baseblock` → 可走 `Across`
|
||||
- **Border(22 格)**:底部一排 + 左右墙 + 顶部墙,围成通道
|
||||
|
||||
与 `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
|
||||
```
|
||||
53
tools/verify-split-build.sh
Normal file
53
tools/verify-split-build.sh
Normal 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
|
||||
111
tools/write-deploy-manifest.js
Normal file
111
tools/write-deploy-manifest.js
Normal 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)})`);
|
||||
}
|
||||
Reference in New Issue
Block a user