exporter.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900
  1. """把某个 game 的产物打包成「可直接拖进 Cocos 的整合包」。
  2. 被 server.py 的 /api/export 调用,也可命令行单独跑:
  3. python exporter.py jelly-candy-slot
  4. 产物在 out/<game>/cocos-pack/ ,结构:
  5. 把素材接进Cocos-零基础教程.md
  6. assets/
  7. resources/characters/*.json|.atlas|.png
  8. resources/vfx/*.json + particle.png
  9. scripts/JellyDemo.ts ParticleConfig.ts TweenPresets.ts
  10. """
  11. import json
  12. import math
  13. import os
  14. import shutil
  15. import asset_quality
  16. HERE = os.path.dirname(os.path.abspath(__file__))
  17. def _symbol_fit_from_library(lib):
  18. slot_config = lib.get("slot_config") or {}
  19. symbol_cfg = (slot_config.get("layout") or {}).get("symbols") or {}
  20. fill = float(symbol_cfg.get("targetCellFill", 0.92) or 0.92)
  21. default_s = float(symbol_cfg.get("defaultScalePerCell", 0.00093) or 0.00093)
  22. default_oyf = float(symbol_cfg.get("defaultOriginYOffsetPerCell", 0.5) or 0.5)
  23. fits = {}
  24. symbol_ids = {s.get("id") for s in (slot_config.get("symbols") or [])}
  25. for item in lib.get("characters", []):
  26. cid = item.get("id")
  27. if symbol_ids and cid not in symbol_ids:
  28. continue
  29. w = float(item.get("w") or 0)
  30. h = float(item.get("h") or 0)
  31. if not cid or w <= 0 or h <= 0:
  32. continue
  33. max_dim = max(w, h)
  34. s = fill / max_dim
  35. if w < h * 0.72:
  36. s *= 1.12
  37. if h < w * 0.72:
  38. s *= 1.08
  39. # Spine 原点在素材底部中心,节点要下移半个实际显示高度才会视觉居中。
  40. oyf = (h * s) / 2.0
  41. fits[cid] = {"s": round(s, 6), "oyf": round(oyf, 4)}
  42. return fits, {"s": round(default_s, 6), "oyf": round(default_oyf, 4)}
  43. def _math_report_md(slot_config):
  44. model = slot_config.get("mathModel") or {}
  45. sim = model.get("simulation") or {}
  46. rng = model.get("rng") or {}
  47. reels = model.get("reelStrips") or []
  48. lines = [
  49. "# Slot Math Report",
  50. "",
  51. "This is a certification-candidate math package, not a third-party lab certificate.",
  52. "",
  53. "## Identity",
  54. f"- Game: `{slot_config.get('game', {}).get('id', '')}`",
  55. f"- Model hash: `{model.get('modelHash', '')}`",
  56. f"- Status: `{model.get('status', '')}`",
  57. "",
  58. "## RTP Summary",
  59. f"- Target RTP: `{sim.get('targetRtp', '')}`",
  60. f"- Estimated RTP: `{sim.get('estimatedRtp', '')}`",
  61. f"- Payout scale: `{model.get('payoutScale', '')}`",
  62. f"- Hit frequency: `{sim.get('hitFrequency', '')}`",
  63. f"- Std dev per spin: `{sim.get('stdDevPerSpin', '')}`",
  64. f"- Base spins: `{sim.get('baseSpins', '')}`",
  65. f"- Total resolved spins: `{sim.get('totalResolvedSpins', '')}`",
  66. "",
  67. "## RNG",
  68. f"- Simulation RNG: `{rng.get('algorithm', '')}`",
  69. f"- Seed: `{rng.get('seed', '')}`",
  70. f"- Production requirement: `{rng.get('productionRequirement', '')}`",
  71. "",
  72. "## Reel Strips",
  73. ]
  74. for i, reel in enumerate(reels):
  75. lines.append(f"- Reel {i + 1} ({len(reel)} stops): `{','.join(reel)}`")
  76. lines.extend([
  77. "",
  78. "## Required Before Real Certification",
  79. "- Freeze source code and generated math config.",
  80. "- Replace prototype random calls with approved production RNG integration.",
  81. "- Run lab-required long simulation volume and edge-case tests.",
  82. "- Submit PAR sheet, reel strips, paytable, feature rules, RNG proof, and game binary.",
  83. ])
  84. return "\n".join(lines) + "\n"
  85. def _image_alpha_stats(path):
  86. try:
  87. from PIL import Image
  88. with Image.open(path) as img:
  89. rgba = img.convert("RGBA")
  90. alpha = rgba.getchannel("A")
  91. bbox = alpha.getbbox()
  92. if not bbox:
  93. return {"ok": False, "reason": "empty_alpha", "size": rgba.size}
  94. pix = list(alpha.getdata())
  95. transparent = sum(1 for v in pix if v == 0)
  96. opaque = sum(1 for v in pix if v > 16)
  97. area = rgba.width * rgba.height
  98. return {
  99. "ok": True,
  100. "size": rgba.size,
  101. "bbox": bbox,
  102. "transparentRatio": round(transparent / max(1, area), 4),
  103. "opaqueRatio": round(opaque / max(1, area), 4),
  104. "bboxRatio": round(((bbox[2] - bbox[0]) * (bbox[3] - bbox[1])) / max(1, area), 4),
  105. }
  106. except Exception as e:
  107. return {"ok": False, "reason": str(e), "size": [0, 0]}
  108. def _qa_add(report, level, code, message, target="", suggestion=""):
  109. report["items"].append({
  110. "level": level,
  111. "code": code,
  112. "target": target,
  113. "message": message,
  114. "suggestion": suggestion,
  115. })
  116. def _qa_report(lib, base, slot_src):
  117. slot_config = lib.get("slot_config") or {}
  118. report = {
  119. "game": lib.get("game"),
  120. "summary": {"error": 0, "warning": 0, "info": 0},
  121. "items": [],
  122. }
  123. chars = {c.get("id"): c for c in lib.get("characters", [])}
  124. ui_art = {a.get("id"): a for a in lib.get("ui_art", [])}
  125. required_art = ["bg_main", "logo", "reel_frame", "btn_spin", "hud_pill"]
  126. for aid in required_art:
  127. path = os.path.join(base, "ui_art", f"{aid}.png")
  128. if aid not in ui_art or not os.path.isfile(path):
  129. _qa_add(report, "error", "missing_ui_art", f"关键 UI 资源缺失:{aid}", aid, "在 UI 美术任务卡片里补生成或重新生成。")
  130. logo_path = os.path.join(base, "ui_art", "logo.png")
  131. if os.path.isfile(logo_path):
  132. st = _image_alpha_stats(logo_path)
  133. if not st.get("ok"):
  134. _qa_add(report, "error", "bad_logo_alpha", "Logo 图片没有有效 Alpha 内容", "logo", "重新生成 logo。")
  135. elif st["opaqueRatio"] < 0.03:
  136. _qa_add(report, "warning", "logo_too_empty", "Logo 有效像素很少,可能是空框或文字缺失。", "logo", "重新生成 logo,并要求真实可读标题文字。")
  137. frame_path = os.path.join(base, "ui_art", "reel_frame.png")
  138. if os.path.isfile(frame_path):
  139. st = _image_alpha_stats(frame_path)
  140. if st.get("ok") and st["transparentRatio"] < 0.25:
  141. _qa_add(report, "warning", "reel_frame_not_hollow", "卷轴框透明区域偏少,可能不是空心框。", "reel_frame", "重新生成空心透明中心的 reel_frame。")
  142. boss = slot_config.get("boss") or {}
  143. if boss.get("enabled"):
  144. boss_id = boss.get("id") or "boss_demon_lord"
  145. row = chars.get(boss_id)
  146. if not row:
  147. _qa_add(report, "error", "missing_boss", f"关主资源缺失:{boss_id}", boss_id, "在角色库补生成关主。")
  148. else:
  149. preview = row.get("preview")
  150. if not preview or not os.path.isfile(os.path.join(base, preview)):
  151. _qa_add(report, "error", "missing_boss_preview", "关主缺少完整预览图,页面会误把 atlas 当角色。", boss_id, "重新生成关主,或让系统补完整 preview。")
  152. else:
  153. ok, reason, detail = asset_quality.boss_preview_quality(os.path.join(base, preview))
  154. if not ok:
  155. _qa_add(
  156. report,
  157. "error",
  158. "bad_boss_preview",
  159. f"关主预览不可用:{reason}",
  160. boss_id,
  161. f"重新生成完整关主预览;当前最大主体占比 {detail.get('largestShare', 0):.0%},明显分离大块 {len(detail.get('significantComponents', []))} 个。",
  162. )
  163. if row.get("type") != "spine_parts":
  164. _qa_add(report, "warning", "boss_not_parts", "关主不是 spine_parts,爆炸拆件动作会受限。", boss_id, "重新生成关主拆件。")
  165. preview_version = row.get("previewVersion", "")
  166. part_versions = row.get("partVersions") or {}
  167. if preview_version:
  168. stale = [
  169. part.get("id", "")
  170. for part in row.get("parts") or []
  171. if part_versions.get(part.get("id", "")) != preview_version
  172. ]
  173. if stale:
  174. _qa_add(
  175. report,
  176. "error",
  177. "boss_parts_version_mismatch",
  178. f"关主拆件与当前主图版本不一致:{', '.join(stale[:6])}",
  179. boss_id,
  180. "点击“按主图重生全部拆件”,让拆件和主图绑定同一版本。",
  181. )
  182. for part in row.get("parts") or []:
  183. pfile = part.get("file", "")
  184. ppath = os.path.join(base, pfile)
  185. if not pfile or not os.path.isfile(ppath):
  186. _qa_add(report, "error", "missing_boss_part", f"关主拆件缺失:{part.get('id', '')}", boss_id, "重新生成关主拆件。")
  187. continue
  188. ok, reason, detail = asset_quality.boss_part_quality(part.get("id", ""), ppath)
  189. if not ok:
  190. _qa_add(
  191. report,
  192. "error",
  193. "bad_boss_part",
  194. f"关主拆件 {part.get('id', '')} 不可用:{reason}",
  195. boss_id,
  196. f"重新生成或重切关主拆件;最大主体 {detail.get('largestShare', 0):.0%},第二主体 {detail.get('secondShare', 0):.0%}。",
  197. )
  198. theme = (slot_config.get("theme") or {}).get("key", "")
  199. title = boss.get("title", "")
  200. if theme in ("jelly", "pirate_jelly") and ("恶魔" in title or "魔王" in title):
  201. _qa_add(report, "warning", "boss_theme_mismatch", "关主名称/设定仍偏暗黑,可能不贴合当前糖果主题。", boss_id, "点击重新生成,使用主题化 boss prompt。")
  202. layout = slot_config.get("layout") or {}
  203. reel = layout.get("reel") or {}
  204. rows = int(reel.get("rows") or 0)
  205. cols = int(reel.get("cols") or 0)
  206. if rows and cols and rows <= 3:
  207. _qa_add(report, "info", "compact_reel_layout", "低行数盘面已使用紧凑布局,避免中间空行过大。", "SlotGame.ts")
  208. if "fillColor = new Color(255, 255, 255, 0)" not in slot_src:
  209. _qa_add(report, "warning", "symbol_card_background", "符号格子仍可能有白色底板。", "SlotGame.ts", "检查 buildGrid,格子底板应为透明或极弱描边。")
  210. if "BOSS_CONFIG.logoSide" not in slot_src:
  211. _qa_add(report, "warning", "boss_logo_position", "关主没有按 logo 旁边定位,可能挤进盘面。", "SlotGame.ts", "检查 buildBoss 的位置。")
  212. for item in report["items"]:
  213. report["summary"][item["level"]] += 1
  214. return report
  215. def _qa_report_md(report):
  216. lines = [
  217. "# Asset QA Report",
  218. "",
  219. f"- Game: `{report.get('game')}`",
  220. f"- Errors: `{report['summary']['error']}`",
  221. f"- Warnings: `{report['summary']['warning']}`",
  222. f"- Info: `{report['summary']['info']}`",
  223. "",
  224. "## Findings",
  225. ]
  226. if not report["items"]:
  227. lines.append("- No QA findings.")
  228. for item in report["items"]:
  229. lines.append(
  230. f"- **{item['level'].upper()}** `{item['code']}` `{item.get('target','')}`: "
  231. f"{item['message']}" + (f" 建议:{item['suggestion']}" if item.get("suggestion") else "")
  232. )
  233. return "\n".join(lines) + "\n"
  234. # ---------------------------------------------------------------- 粒子贴图
  235. def _write_particle_png(path):
  236. """生成一张柔光圆点透明 PNG(粒子配置引用的 particle.png)。"""
  237. try:
  238. from PIL import Image
  239. except ImportError:
  240. return False
  241. S = 64
  242. im = Image.new("RGBA", (S, S), (0, 0, 0, 0))
  243. px = im.load()
  244. c = (S - 1) / 2.0
  245. r = c
  246. for y in range(S):
  247. for x in range(S):
  248. d = math.hypot(x - c, y - c) / r
  249. a = max(0.0, 1.0 - d)
  250. a = a * a
  251. px[x, y] = (255, 255, 255, int(255 * a))
  252. im.save(path)
  253. return True
  254. # ---------------------------------------------------------------- 静态脚本:ParticleConfig.ts
  255. PARTICLE_CONFIG_TS = r"""// 自动生成 by anim_studio —— 把 *.json 粒子配置应用到 Cocos ParticleSystem2D
  256. // 这个文件你不用改。
  257. import { ParticleSystem2D, Color, Vec2, SpriteFrame, gfx } from 'cc';
  258. function toBlend(gl: number): number {
  259. switch (gl) {
  260. case 0: return gfx.BlendFactor.ZERO;
  261. case 1: return gfx.BlendFactor.ONE;
  262. case 768: return gfx.BlendFactor.SRC_COLOR;
  263. case 769: return gfx.BlendFactor.ONE_MINUS_SRC_COLOR;
  264. case 770: return gfx.BlendFactor.SRC_ALPHA;
  265. case 771: return gfx.BlendFactor.ONE_MINUS_SRC_ALPHA;
  266. case 772: return gfx.BlendFactor.DST_ALPHA;
  267. case 773: return gfx.BlendFactor.ONE_MINUS_DST_ALPHA;
  268. default: return gfx.BlendFactor.ONE;
  269. }
  270. }
  271. function col(arr: number[] | undefined, def: number[]): Color {
  272. const a = arr && arr.length >= 3 ? arr : def;
  273. return new Color(a[0] | 0, a[1] | 0, a[2] | 0, a.length > 3 ? (a[3] | 0) : 255);
  274. }
  275. export function applyParticleConfig(ps: ParticleSystem2D, c: any, spriteFrame: SpriteFrame) {
  276. ps.spriteFrame = spriteFrame;
  277. ps.emitterMode = ParticleSystem2D.EmitterMode.GRAVITY;
  278. ps.duration = c.duration ?? -1;
  279. ps.totalParticles = c.totalParticles ?? 200;
  280. ps.emissionRate = c.emissionRate ?? 60;
  281. ps.life = c.life ?? 2;
  282. ps.lifeVar = c.lifeVar ?? 0;
  283. ps.angle = c.angle ?? 90;
  284. ps.angleVar = c.angleVar ?? 0;
  285. ps.speed = c.speed ?? 100;
  286. ps.speedVar = c.speedVar ?? 0;
  287. ps.gravity = new Vec2(c.gravityX ?? 0, c.gravityY ?? 0);
  288. ps.posVar = new Vec2(c.posVarX ?? 0, c.posVarY ?? 0);
  289. ps.startSize = c.startSize ?? 30;
  290. ps.startSizeVar = c.startSizeVar ?? 0;
  291. ps.endSize = c.endSize ?? -1;
  292. ps.startSpin = c.startSpin ?? 0;
  293. ps.startSpinVar = c.startSpinVar ?? 0;
  294. ps.endSpin = c.endSpin ?? 0;
  295. ps.endSpinVar = c.endSpinVar ?? 0;
  296. ps.startColor = col(c.startColor, [255, 255, 255, 255]);
  297. ps.startColorVar = col(c.startColorVar, [0, 0, 0, 0]);
  298. ps.endColor = col(c.endColor, [255, 255, 255, 0]);
  299. ps.endColorVar = col(c.endColorVar, [0, 0, 0, 0]);
  300. if (c.blendFunc) {
  301. ps.srcBlendFactor = toBlend(c.blendFunc.src);
  302. ps.dstBlendFactor = toBlend(c.blendFunc.dst);
  303. }
  304. ps.resetSystem();
  305. }
  306. """
  307. # ---------------------------------------------------------------- 模板脚本:JellyDemo.ts
  308. # __CHARACTERS__ / __VFX__ 会被替换成该 game 真实的资源 id 列表
  309. JELLY_DEMO_TS = r"""// =============================================================
  310. // JellyDemo.ts —— 一键演示:加载全部角色 + 按钮触发 WIN / 特效
  311. // by anim_studio(按本 game 的资源自动生成)
  312. // 用法:把本脚本拖到场景里一个空节点上,点播放。
  313. // =============================================================
  314. import {
  315. _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame,
  316. ParticleSystem2D, UITransform, Label, Graphics, Button, Color,
  317. view, EventTouch,
  318. } from 'cc';
  319. import { applyParticleConfig } from './ParticleConfig';
  320. // 需要 UI 动效时:import { TweenPresets } from './TweenPresets';
  321. // TweenPresets.play('scale_bounce', someNode).start();
  322. const { ccclass } = _decorator;
  323. const CHARACTERS = __CHARACTERS__;
  324. const VFX = __VFX__;
  325. const CHAR_SCALE = 0.11;
  326. @ccclass('JellyDemo')
  327. export class JellyDemo extends Component {
  328. private skeletons: sp.Skeleton[] = [];
  329. private particleTex: SpriteFrame | null = null;
  330. onLoad() {
  331. const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
  332. const size = view.getVisibleSize();
  333. ut.setContentSize(size.width, size.height);
  334. this.loadParticleTexture(() => { this.buildCharacterGrid(); this.buildButtons(); });
  335. }
  336. private loadParticleTexture(done: () => void) {
  337. resources.load('vfx/particle/spriteFrame', SpriteFrame, (err, sf) => {
  338. if (!err) this.particleTex = sf;
  339. else console.warn('[JellyDemo] 粒子贴图未加载到:', err);
  340. done();
  341. });
  342. }
  343. private buildCharacterGrid() {
  344. const cols = 5, cellW = 175, cellH = 200;
  345. const rows = Math.ceil(CHARACTERS.length / cols);
  346. const startX = -((cols - 1) * cellW) / 2;
  347. const startY = ((rows - 1) * cellH) / 2 + 40;
  348. CHARACTERS.forEach((id, i) => {
  349. resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => {
  350. if (err) { console.error('[JellyDemo] 角色加载失败:', id, err); return; }
  351. const node = new Node(id); node.parent = this.node;
  352. const sk = node.addComponent(sp.Skeleton);
  353. sk.skeletonData = data;
  354. sk.premultipliedAlpha = false;
  355. sk.setAnimation(0, 'idle', true);
  356. node.setScale(CHAR_SCALE, CHAR_SCALE, 1);
  357. const c = i % cols, r = Math.floor(i / cols);
  358. node.setPosition(startX + c * cellW, startY - r * cellH, 0);
  359. this.skeletons.push(sk);
  360. this.makeLabel(this.node, id, startX + c * cellW, startY - r * cellH - 70, 16);
  361. });
  362. });
  363. }
  364. private buildButtons() {
  365. const y = -view.getVisibleSize().height / 2 + 60;
  366. this.makeButton('▶ 全部 WIN', -300, y, new Color(255, 120, 60, 255), () => {
  367. this.skeletons.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); });
  368. });
  369. VFX.forEach((id, i) => {
  370. const x = -90 + i * 165;
  371. this.makeButton(id, x, y, new Color(80, 150, 255, 255), () => this.playVfx(id));
  372. });
  373. }
  374. private playVfx(id: string) {
  375. resources.load(`vfx/${id}`, JsonAsset, (err, asset) => {
  376. if (err) { console.error('[JellyDemo] 特效配置加载失败:', id, err); return; }
  377. const node = new Node('vfx_' + id); node.parent = this.node; node.setPosition(0, 80, 0);
  378. const ps = node.addComponent(ParticleSystem2D);
  379. applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame);
  380. this.scheduleOnce(() => { ps.stopSystem(); }, 1.5);
  381. this.scheduleOnce(() => { node.destroy(); }, 5);
  382. });
  383. }
  384. private makeButton(text: string, x: number, y: number, color: Color, onClick: () => void) {
  385. const node = new Node('btn_' + text); node.parent = this.node; node.setPosition(x, y, 0);
  386. const w = 150, h = 52;
  387. const ut = node.addComponent(UITransform); ut.setContentSize(w, h);
  388. const g = node.addComponent(Graphics); g.fillColor = color;
  389. this.roundRect(g, -w / 2, -h / 2, w, h, 12); g.fill();
  390. this.makeLabel(node, text, 0, 0, 22, new Color(255, 255, 255, 255));
  391. const btn = node.addComponent(Button);
  392. btn.transition = Button.Transition.SCALE; btn.zoomScale = 0.92;
  393. node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => onClick());
  394. }
  395. private makeLabel(parent: Node, text: string, x: number, y: number, size: number, color?: Color) {
  396. const n = new Node('label'); n.parent = parent; n.setPosition(x, y, 0);
  397. const lab = n.addComponent(Label);
  398. lab.string = text; lab.fontSize = size; lab.lineHeight = size + 2;
  399. lab.color = color || new Color(60, 60, 60, 255);
  400. const ps = parent.scale;
  401. if (ps.x !== 0 && ps.x !== 1) n.setScale(1 / ps.x, 1 / ps.y, 1);
  402. }
  403. private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) {
  404. g.moveTo(x + r, y); g.lineTo(x + w - r, y);
  405. g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false);
  406. g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false);
  407. g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false);
  408. g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close();
  409. }
  410. }
  411. """
  412. # ---------------------------------------------------------------- 模板脚本:SlotGame.ts
  413. # __SYMBOLS__ 会被替换成本 game 的符号列表
  414. SLOT_GAME_TS = r"""// =============================================================
  415. // SlotGame.ts —— 果冻老虎机(可玩原型)by anim_studio(按本 game 资源生成)
  416. // 用法:把本脚本挂到 Canvas 下的一个空节点上,点播放。
  417. // =============================================================
  418. import {
  419. _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame,
  420. ParticleSystem2D, UITransform, Label, Graphics, Button, Color, Vec3,
  421. view, EventTouch, tween, profiler,
  422. } from 'cc';
  423. import { applyParticleConfig } from './ParticleConfig';
  424. const { ccclass } = _decorator;
  425. const SYMBOLS = __SYMBOLS__;
  426. const COLS = 5, ROWS = 3;
  427. const CELL = 118;
  428. const SYM_SCALE = 0.085;
  429. const BET = 50;
  430. const START_BALANCE = 1000;
  431. @ccclass('SlotGame')
  432. export class SlotGame extends Component {
  433. private dataMap: Record<string, sp.SkeletonData> = {};
  434. private cells: sp.Skeleton[][] = [];
  435. private cur: string[][] = [];
  436. private finalGrid: string[][] = [];
  437. private spinning = false;
  438. private elapsed = 0;
  439. private colStopAt: number[] = [];
  440. private colStopped: boolean[] = [];
  441. private swapTimer: number[] = [];
  442. private balance = START_BALANCE;
  443. private displayBalance = START_BALANCE;
  444. private balanceLabel!: Label;
  445. private winLabel!: Label;
  446. private spinBtn!: Node;
  447. private particleTex: SpriteFrame | null = null;
  448. private gridY = 30;
  449. private startX = -((COLS - 1) * CELL) / 2;
  450. onLoad() {
  451. profiler && profiler.hideStats();
  452. this.node.setPosition(0, 0, 0);
  453. const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
  454. ut.setAnchorPoint(0.5, 0.5);
  455. const s = view.getVisibleSize();
  456. ut.setContentSize(s.width, s.height);
  457. this.buildBackground(s.width, s.height);
  458. this.buildPanel();
  459. this.buildHud(s.width, s.height);
  460. this.buildSpinButton(s.height);
  461. let left = SYMBOLS.length;
  462. resources.load('vfx/particle/spriteFrame', SpriteFrame, (_e, sf) => { if (sf) this.particleTex = sf; });
  463. SYMBOLS.forEach((id) => {
  464. resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => {
  465. if (!err) this.dataMap[id] = data;
  466. if (--left === 0) this.buildGrid();
  467. });
  468. });
  469. }
  470. private buildBackground(W: number, H: number) {
  471. const n = new Node('bg'); n.parent = this.node; n.setSiblingIndex(0);
  472. const g = n.addComponent(Graphics);
  473. g.fillColor = new Color(40, 24, 64, 255);
  474. g.rect(-W / 2, -H / 2, W, H); g.fill();
  475. g.fillColor = new Color(58, 34, 92, 255);
  476. g.rect(-W / 2, H / 2 - 90, W, 90); g.fill();
  477. const t = new Node('title'); t.parent = this.node; t.setPosition(0, H / 2 - 45, 0);
  478. const lab = t.addComponent(Label);
  479. lab.string = '🍬 JELLY SLOT 🍬'; lab.fontSize = 34; lab.lineHeight = 38;
  480. lab.color = new Color(255, 235, 160, 255);
  481. }
  482. private buildPanel() {
  483. const w = COLS * CELL + 30, h = ROWS * CELL + 30;
  484. const n = new Node('panel'); n.parent = this.node; n.setPosition(0, this.gridY, 0);
  485. const g = n.addComponent(Graphics);
  486. g.fillColor = new Color(24, 14, 38, 255);
  487. this.roundRect(g, -w / 2, -h / 2, w, h, 18); g.fill();
  488. const tile = CELL - 12;
  489. for (let c = 0; c < COLS; c++) {
  490. for (let r = 0; r < ROWS; r++) {
  491. const cx = this.startX + c * CELL;
  492. const cy = (1 - r) * CELL;
  493. g.fillColor = new Color(247, 243, 252, 255);
  494. this.roundRect(g, cx - tile / 2, cy - tile / 2, tile, tile, 14); g.fill();
  495. g.lineWidth = 3; g.strokeColor = new Color(255, 205, 110, 200);
  496. this.roundRect(g, cx - tile / 2, cy - tile / 2, tile, tile, 14); g.stroke();
  497. }
  498. }
  499. g.lineWidth = 6; g.strokeColor = new Color(255, 200, 90, 255);
  500. this.roundRect(g, -w / 2, -h / 2, w, h, 18); g.stroke();
  501. }
  502. private buildHud(W: number, H: number) {
  503. const b = new Node('balance'); b.parent = this.node; b.setPosition(-W / 2 + 130, H / 2 - 130, 0);
  504. this.balanceLabel = b.addComponent(Label);
  505. this.balanceLabel.fontSize = 26; this.balanceLabel.color = new Color(180, 240, 255, 255);
  506. const w = new Node('win'); w.parent = this.node; w.setPosition(0, H / 2 - 130, 0);
  507. this.winLabel = w.addComponent(Label);
  508. this.winLabel.fontSize = 30; this.winLabel.color = new Color(255, 220, 120, 255);
  509. this.winLabel.string = '';
  510. this.refreshBalance();
  511. }
  512. private refreshBalance() { this.balanceLabel.string = `💰 ${Math.floor(this.displayBalance)}`; }
  513. private buildSpinButton(H: number) {
  514. const node = new Node('spin'); node.parent = this.node;
  515. node.setPosition(0, -H / 2 + 70, 0);
  516. const w = 210, h = 64;
  517. node.addComponent(UITransform).setContentSize(w, h);
  518. const g = node.addComponent(Graphics);
  519. g.fillColor = new Color(255, 110, 70, 255);
  520. this.roundRect(g, -w / 2, -h / 2, w, h, 16); g.fill();
  521. const ln = new Node('t'); ln.parent = node;
  522. const lab = ln.addComponent(Label);
  523. lab.string = '▶ SPIN'; lab.fontSize = 30; lab.color = new Color(255, 255, 255, 255);
  524. const btn = node.addComponent(Button);
  525. btn.transition = Button.Transition.SCALE; btn.zoomScale = 0.93;
  526. node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => this.spin());
  527. this.spinBtn = node;
  528. }
  529. private buildGrid() {
  530. for (let c = 0; c < COLS; c++) {
  531. this.cells[c] = []; this.cur[c] = []; this.finalGrid[c] = [];
  532. for (let r = 0; r < ROWS; r++) {
  533. const id = this.rand();
  534. const node = new Node(`cell_${c}_${r}`); node.parent = this.node;
  535. node.setPosition(this.startX + c * CELL, this.cellY(r), 0);
  536. node.setScale(SYM_SCALE, SYM_SCALE, 1);
  537. const sk = node.addComponent(sp.Skeleton);
  538. sk.skeletonData = this.dataMap[id];
  539. sk.premultipliedAlpha = false;
  540. sk.setAnimation(0, 'idle', true);
  541. this.cells[c][r] = sk; this.cur[c][r] = id;
  542. }
  543. }
  544. }
  545. private cellY(r: number) { return this.gridY + (1 - r) * CELL; }
  546. private spin() {
  547. if (this.spinning) return;
  548. if (this.balance < BET) { this.flashWin('余额不足!'); return; }
  549. this.balance -= BET; this.displayBalance = this.balance; this.refreshBalance();
  550. this.winLabel.string = '';
  551. this.decideResult();
  552. this.spinning = true; this.elapsed = 0;
  553. for (let c = 0; c < COLS; c++) { this.colStopped[c] = false; this.colStopAt[c] = 0.6 + c * 0.28; this.swapTimer[c] = 0; }
  554. this.setSpinEnabled(false);
  555. }
  556. private decideResult() {
  557. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) this.finalGrid[c][r] = this.rand();
  558. if (Math.random() < 0.4) {
  559. const r = Math.floor(Math.random() * ROWS);
  560. const sym = this.rand();
  561. const len = Math.random() < 0.4 ? 4 : 3;
  562. for (let c = 0; c < len && c < COLS; c++) this.finalGrid[c][r] = sym;
  563. }
  564. }
  565. update(dt: number) {
  566. if (!this.spinning) {
  567. if (Math.abs(this.displayBalance - this.balance) > 0.5) {
  568. this.displayBalance += (this.balance - this.displayBalance) * Math.min(1, dt * 6);
  569. this.refreshBalance();
  570. }
  571. return;
  572. }
  573. this.elapsed += dt;
  574. let allStopped = true;
  575. for (let c = 0; c < COLS; c++) {
  576. if (this.colStopped[c]) continue;
  577. if (this.elapsed >= this.colStopAt[c]) {
  578. for (let r = 0; r < ROWS; r++) this.setSymbol(c, r, this.finalGrid[c][r]);
  579. this.colStopped[c] = true; this.popColumn(c);
  580. } else {
  581. this.swapTimer[c] -= dt;
  582. if (this.swapTimer[c] <= 0) { this.swapTimer[c] = 0.06; for (let r = 0; r < ROWS; r++) this.setSymbol(c, r, this.rand()); }
  583. allStopped = false;
  584. }
  585. }
  586. if (allStopped) { this.spinning = false; this.evaluate(); this.setSpinEnabled(true); }
  587. }
  588. private setSymbol(c: number, r: number, id: string) {
  589. const sk = this.cells[c][r];
  590. if (this.cur[c][r] !== id) { sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false; this.cur[c][r] = id; }
  591. sk.setAnimation(0, 'idle', true);
  592. }
  593. private popColumn(c: number) {
  594. for (let r = 0; r < ROWS; r++) {
  595. const n = this.cells[c][r].node;
  596. n.setScale(SYM_SCALE * 1.25, SYM_SCALE * 1.25, 1);
  597. tween(n).to(0.12, { scale: new Vec3(SYM_SCALE, SYM_SCALE, 1) }, { easing: 'backOut' }).start();
  598. }
  599. }
  600. private evaluate() {
  601. let totalWin = 0; const winners: sp.Skeleton[] = [];
  602. for (let r = 0; r < ROWS; r++) {
  603. const first = this.finalGrid[0][r]; let count = 1;
  604. while (count < COLS && this.finalGrid[count][r] === first) count++;
  605. if (count >= 3) {
  606. totalWin += BET * (count - 2) * 2;
  607. for (let c = 0; c < count; c++) winners.push(this.cells[c][r]);
  608. }
  609. }
  610. if (totalWin > 0) {
  611. this.balance += totalWin; this.flashWin(`WIN +${totalWin}`);
  612. winners.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); });
  613. this.playCoinRain();
  614. } else { this.winLabel.string = ''; }
  615. }
  616. private flashWin(text: string) {
  617. this.winLabel.string = text;
  618. const n = this.winLabel.node; n.setScale(0.6, 0.6, 1);
  619. tween(n).to(0.35, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' }).start();
  620. }
  621. private playCoinRain() {
  622. resources.load('vfx/coin_rain', JsonAsset, (err, asset) => {
  623. if (err) return;
  624. const n = new Node('coins'); n.parent = this.node; n.setPosition(0, 120, 0);
  625. const ps = n.addComponent(ParticleSystem2D);
  626. applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame);
  627. this.scheduleOnce(() => ps.stopSystem(), 1.6);
  628. this.scheduleOnce(() => n.destroy(), 5);
  629. });
  630. }
  631. private rand() { return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]; }
  632. private setSpinEnabled(on: boolean) {
  633. const btn = this.spinBtn.getComponent(Button); if (btn) btn.interactable = on;
  634. this.spinBtn.getChildByName('t')!.getComponent(Label)!.string = on ? '▶ SPIN' : '转动中…';
  635. }
  636. private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) {
  637. g.moveTo(x + r, y); g.lineTo(x + w - r, y);
  638. g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false);
  639. g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false);
  640. g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false);
  641. g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close();
  642. }
  643. }
  644. """
  645. # ---------------------------------------------------------------- 教程
  646. def _tutorial_md(game):
  647. return f"""# 把「{game}」素材接进 Cocos —— 零基础教程
  648. 这个文件夹是你点「导出 Cocos 整合包」自动生成的,可以直接进 Cocos Creator。
  649. 全程不用写代码:装软件 → 新建项目 → 拖文件 → 挂一个脚本 → 点播放。
  650. ## 包里有什么
  651. ```
  652. cocos-pack/
  653. ├─ 本教程.md
  654. └─ assets/ ← 整个拖进 Cocos
  655. ├─ resources/characters/ 角色三件套(.json/.atlas/.png)
  656. ├─ resources/vfx/ 粒子配置 .json + particle.png
  657. └─ scripts/ JellyDemo.ts / ParticleConfig.ts / TweenPresets.ts
  658. ```
  659. > `resources` 文件夹名不能改,Cocos 靠它动态加载资源。
  660. ## 步骤
  661. 1. 装 **Cocos Creator 3.8.x**:https://www.cocos.com/creator-download
  662. 2. 新建一个 **空项目(Empty / 2D)** 并打开。
  663. 3. 把本包 `assets/` 里的 **resources、scripts 两个文件夹**一起拖进 Cocos 资源管理器的 `assets` 上,等导入进度条走完。
  664. 4. 在 `assets` 右键 → 新建 Scene,双击打开;确保有个 **Canvas** 节点,在它下面新建一个**空节点**。
  665. 5. 把 `scripts/JellyDemo.ts` 拖到那个空节点的属性检查器上(或「添加组件 → JellyDemo」)。
  666. 6. 点正上方 **▶ 播放**:角色排队果冻抖动,底部按钮可触发「全部 WIN」和各粒子特效。
  667. ## 常见问题
  668. - 角色没出来:多半素材没导完或 resources 被改名;确认能在资源管理器把角色展开成 SkeletonData。
  669. - 角色发黑/白边:脚本已设 premultipliedAlpha=false;仍有则在贴图设置关掉 PremultiplyAlpha 重新导入。
  670. - 太大/太小:改 `JellyDemo.ts` 顶部 `CHAR_SCALE`。
  671. - 特效看不见:确认 `resources/vfx/particle.png` 在。
  672. """
  673. # ---------------------------------------------------------------- 主函数
  674. def export(game, out_root, log=print):
  675. base = os.path.join(out_root, game)
  676. if not os.path.isfile(os.path.join(base, "library.json")):
  677. raise FileNotFoundError(f"找不到 game「{game}」的 library.json")
  678. lib = json.load(open(os.path.join(base, "library.json"), encoding="utf-8"))
  679. boss = lib.get("slot_config", {}).get("boss", {})
  680. if boss.get("enabled"):
  681. boss_id = boss.get("id") or "boss_demon_lord"
  682. lib_char_ids = {c.get("id") for c in lib.get("characters", [])}
  683. missing_files = [
  684. f"characters/{boss_id}.{ext}"
  685. for ext in ("json", "atlas", "png")
  686. if not os.path.isfile(os.path.join(base, "characters", f"{boss_id}.{ext}"))
  687. ]
  688. if boss_id not in lib_char_ids or missing_files:
  689. raise RuntimeError(
  690. f"当前资源库缺少关主大魔王资源:{boss_id}。请先在角色库任务卡片里补生成该资源,"
  691. "成功后才会包含 idle/watch/coin_throw/taunt/stomp/explode 等动作。"
  692. )
  693. pack = os.path.join(base, "cocos-pack")
  694. if os.path.exists(pack):
  695. shutil.rmtree(pack)
  696. res_ch = os.path.join(pack, "assets", "resources", "characters")
  697. res_vfx = os.path.join(pack, "assets", "resources", "vfx")
  698. scripts = os.path.join(pack, "assets", "scripts")
  699. for d in (res_ch, res_vfx, scripts):
  700. os.makedirs(d, exist_ok=True)
  701. # 角色三件套
  702. char_ids = []
  703. src_ch = os.path.join(base, "characters")
  704. if os.path.isdir(src_ch):
  705. for f in sorted(os.listdir(src_ch)):
  706. src_path = os.path.join(src_ch, f)
  707. dst_path = os.path.join(res_ch, f)
  708. if os.path.isdir(src_path):
  709. shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
  710. continue
  711. shutil.copy2(src_path, dst_path)
  712. if f.endswith(".json"):
  713. char_ids.append(f[:-5])
  714. log(f"📦 角色 {len(char_ids)} 个")
  715. # 粒子配置:去掉 .particle 后缀,路径更干净
  716. vfx_ids = []
  717. src_vfx = os.path.join(base, "vfx")
  718. if os.path.isdir(src_vfx):
  719. for f in sorted(os.listdir(src_vfx)):
  720. if f.endswith(".particle.json"):
  721. vid = f[: -len(".particle.json")]
  722. shutil.copy2(os.path.join(src_vfx, f), os.path.join(res_vfx, vid + ".json"))
  723. vfx_ids.append(vid)
  724. elif f.endswith(".json"):
  725. shutil.copy2(os.path.join(src_vfx, f), os.path.join(res_vfx, f))
  726. vfx_ids.append(f[:-5])
  727. log(f"📦 特效 {len(vfx_ids)} 个")
  728. # UI 美术(背景 / 外框 / 按钮 / Logo 等整图)
  729. src_art = os.path.join(base, "ui_art")
  730. art_ids = []
  731. if os.path.isdir(src_art):
  732. res_art = os.path.join(pack, "assets", "resources", "ui_art")
  733. os.makedirs(res_art, exist_ok=True)
  734. for f in sorted(os.listdir(src_art)):
  735. if f.endswith(".png"):
  736. shutil.copy2(os.path.join(src_art, f), os.path.join(res_art, f))
  737. art_ids.append(f[:-4])
  738. log(f"📦 UI 美术 {len(art_ids)} 张" if art_ids else "📦 无 UI 美术")
  739. # 粒子贴图
  740. if _write_particle_png(os.path.join(res_vfx, "particle.png")):
  741. log("📦 粒子贴图 particle.png 已生成")
  742. else:
  743. log("⚠️ 未装 Pillow,未能生成 particle.png(特效会发射但看不见)")
  744. # 脚本
  745. demo = (JELLY_DEMO_TS
  746. .replace("__CHARACTERS__", json.dumps(char_ids, ensure_ascii=False))
  747. .replace("__VFX__", json.dumps(vfx_ids, ensure_ascii=False)))
  748. open(os.path.join(scripts, "JellyDemo.ts"), "w", encoding="utf-8").write(demo)
  749. open(os.path.join(scripts, "ParticleConfig.ts"), "w", encoding="utf-8").write(PARTICLE_CONFIG_TS)
  750. slot_tmpl = os.path.join(HERE, "templates", "SlotGame.ts")
  751. slot_src = open(slot_tmpl, encoding="utf-8").read() if os.path.isfile(slot_tmpl) else SLOT_GAME_TS
  752. slot_config = lib.get("slot_config") or {}
  753. configured_symbols = [s.get("id") for s in (slot_config.get("symbols") or []) if s.get("id")]
  754. slot_symbol_ids = [sid for sid in configured_symbols if sid in char_ids] or char_ids
  755. symbol_fit, symbol_fit_default = _symbol_fit_from_library(lib)
  756. game_config_ts = (
  757. "export const SLOT_CONFIG = "
  758. + json.dumps(slot_config, ensure_ascii=False, indent=2)
  759. + " as const;\n"
  760. + "export const SYMBOLS = "
  761. + json.dumps(slot_symbol_ids, ensure_ascii=False, indent=2)
  762. + " as const;\n"
  763. + "export const SYMBOL_FIT = "
  764. + json.dumps(symbol_fit, ensure_ascii=False, indent=2)
  765. + " as const;\n"
  766. )
  767. open(os.path.join(scripts, "GameConfig.ts"), "w", encoding="utf-8").write(game_config_ts)
  768. open(os.path.join(pack, "SlotMathReport.md"), "w", encoding="utf-8").write(_math_report_md(slot_config))
  769. slot = (slot_src
  770. .replace("__SYMBOLS__", json.dumps(slot_symbol_ids, ensure_ascii=False))
  771. .replace("__GAME_CONFIG__", json.dumps(slot_config, ensure_ascii=False))
  772. .replace("__SYMBOL_FIT__", json.dumps(symbol_fit, ensure_ascii=False))
  773. .replace("__SYMBOL_FIT_DEFAULT__", json.dumps(symbol_fit_default, ensure_ascii=False)))
  774. open(os.path.join(scripts, "SlotGame.ts"), "w", encoding="utf-8").write(slot)
  775. qa = _qa_report(lib, base, slot_src)
  776. open(os.path.join(pack, "QAReport.json"), "w", encoding="utf-8").write(
  777. json.dumps(qa, ensure_ascii=False, indent=2)
  778. )
  779. open(os.path.join(pack, "QAReport.md"), "w", encoding="utf-8").write(_qa_report_md(qa))
  780. if qa["summary"]["error"] or qa["summary"]["warning"]:
  781. log(f"🧪 QA:{qa['summary']['error']} 个错误,{qa['summary']['warning']} 个警告;详见 QAReport.md")
  782. for item in qa["items"][:6]:
  783. if item["level"] in ("error", "warning"):
  784. log(f"🧪 [{item['level']}] {item['target']} · {item['message']}")
  785. if qa["summary"]["error"]:
  786. raise RuntimeError(f"导出 QA 未通过:{qa['summary']['error']} 个错误。请查看 {os.path.join(pack, 'QAReport.md')}")
  787. else:
  788. log("🧪 QA:通过,未发现错误或警告")
  789. src_tween = os.path.join(base, "ui", "TweenPresets.ts")
  790. if os.path.isfile(src_tween):
  791. shutil.copy2(src_tween, os.path.join(scripts, "TweenPresets.ts"))
  792. log("📦 脚本 JellyDemo.ts / SlotGame.ts / GameConfig.ts / ParticleConfig.ts / TweenPresets.ts 已写入")
  793. # 教程
  794. open(os.path.join(pack, "把素材接进Cocos-零基础教程.md"), "w",
  795. encoding="utf-8").write(_tutorial_md(game))
  796. log(f"✅ 整合包完成:out/{game}/cocos-pack/")
  797. return pack
  798. if __name__ == "__main__":
  799. import sys
  800. g = sys.argv[1] if len(sys.argv) > 1 else "game"
  801. export(g, os.path.join(HERE, "out"))