"""把某个 game 的产物打包成「可直接拖进 Cocos 的整合包」。 被 server.py 的 /api/export 调用,也可命令行单独跑: python exporter.py jelly-candy-slot 产物在 out//cocos-pack/ ,结构: 把素材接进Cocos-零基础教程.md assets/ resources/characters/*.json|.atlas|.png resources/vfx/*.json + particle.png scripts/JellyDemo.ts ParticleConfig.ts TweenPresets.ts """ import json import math import os import shutil HERE = os.path.dirname(os.path.abspath(__file__)) def _symbol_fit_from_library(lib): slot_config = lib.get("slot_config") or {} symbol_cfg = (slot_config.get("layout") or {}).get("symbols") or {} fill = float(symbol_cfg.get("targetCellFill", 0.92) or 0.92) default_s = float(symbol_cfg.get("defaultScalePerCell", 0.00093) or 0.00093) default_oyf = float(symbol_cfg.get("defaultOriginYOffsetPerCell", 0.5) or 0.5) fits = {} symbol_ids = {s.get("id") for s in (slot_config.get("symbols") or [])} for item in lib.get("characters", []): cid = item.get("id") if symbol_ids and cid not in symbol_ids: continue w = float(item.get("w") or 0) h = float(item.get("h") or 0) if not cid or w <= 0 or h <= 0: continue max_dim = max(w, h) s = fill / max_dim if w < h * 0.72: s *= 1.12 if h < w * 0.72: s *= 1.08 # Spine 原点在素材底部中心,节点要下移半个实际显示高度才会视觉居中。 oyf = (h * s) / 2.0 fits[cid] = {"s": round(s, 6), "oyf": round(oyf, 4)} return fits, {"s": round(default_s, 6), "oyf": round(default_oyf, 4)} def _math_report_md(slot_config): model = slot_config.get("mathModel") or {} sim = model.get("simulation") or {} rng = model.get("rng") or {} reels = model.get("reelStrips") or [] lines = [ "# Slot Math Report", "", "This is a certification-candidate math package, not a third-party lab certificate.", "", "## Identity", f"- Game: `{slot_config.get('game', {}).get('id', '')}`", f"- Model hash: `{model.get('modelHash', '')}`", f"- Status: `{model.get('status', '')}`", "", "## RTP Summary", f"- Target RTP: `{sim.get('targetRtp', '')}`", f"- Estimated RTP: `{sim.get('estimatedRtp', '')}`", f"- Payout scale: `{model.get('payoutScale', '')}`", f"- Hit frequency: `{sim.get('hitFrequency', '')}`", f"- Std dev per spin: `{sim.get('stdDevPerSpin', '')}`", f"- Base spins: `{sim.get('baseSpins', '')}`", f"- Total resolved spins: `{sim.get('totalResolvedSpins', '')}`", "", "## RNG", f"- Simulation RNG: `{rng.get('algorithm', '')}`", f"- Seed: `{rng.get('seed', '')}`", f"- Production requirement: `{rng.get('productionRequirement', '')}`", "", "## Reel Strips", ] for i, reel in enumerate(reels): lines.append(f"- Reel {i + 1} ({len(reel)} stops): `{','.join(reel)}`") lines.extend([ "", "## Required Before Real Certification", "- Freeze source code and generated math config.", "- Replace prototype random calls with approved production RNG integration.", "- Run lab-required long simulation volume and edge-case tests.", "- Submit PAR sheet, reel strips, paytable, feature rules, RNG proof, and game binary.", ]) return "\n".join(lines) + "\n" def _image_alpha_stats(path): try: from PIL import Image with Image.open(path) as img: rgba = img.convert("RGBA") alpha = rgba.getchannel("A") bbox = alpha.getbbox() if not bbox: return {"ok": False, "reason": "empty_alpha", "size": rgba.size} pix = list(alpha.getdata()) transparent = sum(1 for v in pix if v == 0) opaque = sum(1 for v in pix if v > 16) area = rgba.width * rgba.height return { "ok": True, "size": rgba.size, "bbox": bbox, "transparentRatio": round(transparent / max(1, area), 4), "opaqueRatio": round(opaque / max(1, area), 4), "bboxRatio": round(((bbox[2] - bbox[0]) * (bbox[3] - bbox[1])) / max(1, area), 4), } except Exception as e: return {"ok": False, "reason": str(e), "size": [0, 0]} def _qa_add(report, level, code, message, target="", suggestion=""): report["items"].append({ "level": level, "code": code, "target": target, "message": message, "suggestion": suggestion, }) def _qa_report(lib, base, slot_src): slot_config = lib.get("slot_config") or {} report = { "game": lib.get("game"), "summary": {"error": 0, "warning": 0, "info": 0}, "items": [], } chars = {c.get("id"): c for c in lib.get("characters", [])} ui_art = {a.get("id"): a for a in lib.get("ui_art", [])} required_art = ["bg_main", "logo", "reel_frame", "btn_spin", "hud_pill"] for aid in required_art: path = os.path.join(base, "ui_art", f"{aid}.png") if aid not in ui_art or not os.path.isfile(path): _qa_add(report, "error", "missing_ui_art", f"关键 UI 资源缺失:{aid}", aid, "在 UI 美术任务卡片里补生成或重新生成。") logo_path = os.path.join(base, "ui_art", "logo.png") if os.path.isfile(logo_path): st = _image_alpha_stats(logo_path) if not st.get("ok"): _qa_add(report, "error", "bad_logo_alpha", "Logo 图片没有有效 Alpha 内容", "logo", "重新生成 logo。") elif st["opaqueRatio"] < 0.03: _qa_add(report, "warning", "logo_too_empty", "Logo 有效像素很少,可能是空框或文字缺失。", "logo", "重新生成 logo,并要求真实可读标题文字。") frame_path = os.path.join(base, "ui_art", "reel_frame.png") if os.path.isfile(frame_path): st = _image_alpha_stats(frame_path) if st.get("ok") and st["transparentRatio"] < 0.25: _qa_add(report, "warning", "reel_frame_not_hollow", "卷轴框透明区域偏少,可能不是空心框。", "reel_frame", "重新生成空心透明中心的 reel_frame。") boss = slot_config.get("boss") or {} if boss.get("enabled"): boss_id = boss.get("id") or "boss_demon_lord" row = chars.get(boss_id) if not row: _qa_add(report, "error", "missing_boss", f"关主资源缺失:{boss_id}", boss_id, "在角色库补生成关主。") else: preview = row.get("preview") if not preview or not os.path.isfile(os.path.join(base, preview)): _qa_add(report, "warning", "missing_boss_preview", "关主缺少完整预览图,页面会误把 atlas 当角色。", boss_id, "重新生成关主,或让系统补完整 preview。") if row.get("type") != "spine_parts": _qa_add(report, "warning", "boss_not_parts", "关主不是 spine_parts,爆炸拆件动作会受限。", boss_id, "重新生成关主拆件。") theme = (slot_config.get("theme") or {}).get("key", "") title = boss.get("title", "") if theme in ("jelly", "pirate_jelly") and ("恶魔" in title or "魔王" in title): _qa_add(report, "warning", "boss_theme_mismatch", "关主名称/设定仍偏暗黑,可能不贴合当前糖果主题。", boss_id, "点击重新生成,使用主题化 boss prompt。") layout = slot_config.get("layout") or {} reel = layout.get("reel") or {} rows = int(reel.get("rows") or 0) cols = int(reel.get("cols") or 0) if rows and cols and rows <= 3: _qa_add(report, "info", "compact_reel_layout", "低行数盘面已使用紧凑布局,避免中间空行过大。", "SlotGame.ts") if "fillColor = new Color(255, 255, 255, 0)" not in slot_src: _qa_add(report, "warning", "symbol_card_background", "符号格子仍可能有白色底板。", "SlotGame.ts", "检查 buildGrid,格子底板应为透明或极弱描边。") if "BOSS_CONFIG.logoSide" not in slot_src: _qa_add(report, "warning", "boss_logo_position", "关主没有按 logo 旁边定位,可能挤进盘面。", "SlotGame.ts", "检查 buildBoss 的位置。") for item in report["items"]: report["summary"][item["level"]] += 1 return report def _qa_report_md(report): lines = [ "# Asset QA Report", "", f"- Game: `{report.get('game')}`", f"- Errors: `{report['summary']['error']}`", f"- Warnings: `{report['summary']['warning']}`", f"- Info: `{report['summary']['info']}`", "", "## Findings", ] if not report["items"]: lines.append("- No QA findings.") for item in report["items"]: lines.append( f"- **{item['level'].upper()}** `{item['code']}` `{item.get('target','')}`: " f"{item['message']}" + (f" 建议:{item['suggestion']}" if item.get("suggestion") else "") ) return "\n".join(lines) + "\n" # ---------------------------------------------------------------- 粒子贴图 def _write_particle_png(path): """生成一张柔光圆点透明 PNG(粒子配置引用的 particle.png)。""" try: from PIL import Image except ImportError: return False S = 64 im = Image.new("RGBA", (S, S), (0, 0, 0, 0)) px = im.load() c = (S - 1) / 2.0 r = c for y in range(S): for x in range(S): d = math.hypot(x - c, y - c) / r a = max(0.0, 1.0 - d) a = a * a px[x, y] = (255, 255, 255, int(255 * a)) im.save(path) return True # ---------------------------------------------------------------- 静态脚本:ParticleConfig.ts PARTICLE_CONFIG_TS = r"""// 自动生成 by anim_studio —— 把 *.json 粒子配置应用到 Cocos ParticleSystem2D // 这个文件你不用改。 import { ParticleSystem2D, Color, Vec2, SpriteFrame, gfx } from 'cc'; function toBlend(gl: number): number { switch (gl) { case 0: return gfx.BlendFactor.ZERO; case 1: return gfx.BlendFactor.ONE; case 768: return gfx.BlendFactor.SRC_COLOR; case 769: return gfx.BlendFactor.ONE_MINUS_SRC_COLOR; case 770: return gfx.BlendFactor.SRC_ALPHA; case 771: return gfx.BlendFactor.ONE_MINUS_SRC_ALPHA; case 772: return gfx.BlendFactor.DST_ALPHA; case 773: return gfx.BlendFactor.ONE_MINUS_DST_ALPHA; default: return gfx.BlendFactor.ONE; } } function col(arr: number[] | undefined, def: number[]): Color { const a = arr && arr.length >= 3 ? arr : def; return new Color(a[0] | 0, a[1] | 0, a[2] | 0, a.length > 3 ? (a[3] | 0) : 255); } export function applyParticleConfig(ps: ParticleSystem2D, c: any, spriteFrame: SpriteFrame) { ps.spriteFrame = spriteFrame; ps.emitterMode = ParticleSystem2D.EmitterMode.GRAVITY; ps.duration = c.duration ?? -1; ps.totalParticles = c.totalParticles ?? 200; ps.emissionRate = c.emissionRate ?? 60; ps.life = c.life ?? 2; ps.lifeVar = c.lifeVar ?? 0; ps.angle = c.angle ?? 90; ps.angleVar = c.angleVar ?? 0; ps.speed = c.speed ?? 100; ps.speedVar = c.speedVar ?? 0; ps.gravity = new Vec2(c.gravityX ?? 0, c.gravityY ?? 0); ps.posVar = new Vec2(c.posVarX ?? 0, c.posVarY ?? 0); ps.startSize = c.startSize ?? 30; ps.startSizeVar = c.startSizeVar ?? 0; ps.endSize = c.endSize ?? -1; ps.startSpin = c.startSpin ?? 0; ps.startSpinVar = c.startSpinVar ?? 0; ps.endSpin = c.endSpin ?? 0; ps.endSpinVar = c.endSpinVar ?? 0; ps.startColor = col(c.startColor, [255, 255, 255, 255]); ps.startColorVar = col(c.startColorVar, [0, 0, 0, 0]); ps.endColor = col(c.endColor, [255, 255, 255, 0]); ps.endColorVar = col(c.endColorVar, [0, 0, 0, 0]); if (c.blendFunc) { ps.srcBlendFactor = toBlend(c.blendFunc.src); ps.dstBlendFactor = toBlend(c.blendFunc.dst); } ps.resetSystem(); } """ # ---------------------------------------------------------------- 模板脚本:JellyDemo.ts # __CHARACTERS__ / __VFX__ 会被替换成该 game 真实的资源 id 列表 JELLY_DEMO_TS = r"""// ============================================================= // JellyDemo.ts —— 一键演示:加载全部角色 + 按钮触发 WIN / 特效 // by anim_studio(按本 game 的资源自动生成) // 用法:把本脚本拖到场景里一个空节点上,点播放。 // ============================================================= import { _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame, ParticleSystem2D, UITransform, Label, Graphics, Button, Color, view, EventTouch, } from 'cc'; import { applyParticleConfig } from './ParticleConfig'; // 需要 UI 动效时:import { TweenPresets } from './TweenPresets'; // TweenPresets.play('scale_bounce', someNode).start(); const { ccclass } = _decorator; const CHARACTERS = __CHARACTERS__; const VFX = __VFX__; const CHAR_SCALE = 0.11; @ccclass('JellyDemo') export class JellyDemo extends Component { private skeletons: sp.Skeleton[] = []; private particleTex: SpriteFrame | null = null; onLoad() { const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform); const size = view.getVisibleSize(); ut.setContentSize(size.width, size.height); this.loadParticleTexture(() => { this.buildCharacterGrid(); this.buildButtons(); }); } private loadParticleTexture(done: () => void) { resources.load('vfx/particle/spriteFrame', SpriteFrame, (err, sf) => { if (!err) this.particleTex = sf; else console.warn('[JellyDemo] 粒子贴图未加载到:', err); done(); }); } private buildCharacterGrid() { const cols = 5, cellW = 175, cellH = 200; const rows = Math.ceil(CHARACTERS.length / cols); const startX = -((cols - 1) * cellW) / 2; const startY = ((rows - 1) * cellH) / 2 + 40; CHARACTERS.forEach((id, i) => { resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => { if (err) { console.error('[JellyDemo] 角色加载失败:', id, err); return; } const node = new Node(id); node.parent = this.node; const sk = node.addComponent(sp.Skeleton); sk.skeletonData = data; sk.premultipliedAlpha = false; sk.setAnimation(0, 'idle', true); node.setScale(CHAR_SCALE, CHAR_SCALE, 1); const c = i % cols, r = Math.floor(i / cols); node.setPosition(startX + c * cellW, startY - r * cellH, 0); this.skeletons.push(sk); this.makeLabel(this.node, id, startX + c * cellW, startY - r * cellH - 70, 16); }); }); } private buildButtons() { const y = -view.getVisibleSize().height / 2 + 60; this.makeButton('▶ 全部 WIN', -300, y, new Color(255, 120, 60, 255), () => { this.skeletons.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); }); }); VFX.forEach((id, i) => { const x = -90 + i * 165; this.makeButton(id, x, y, new Color(80, 150, 255, 255), () => this.playVfx(id)); }); } private playVfx(id: string) { resources.load(`vfx/${id}`, JsonAsset, (err, asset) => { if (err) { console.error('[JellyDemo] 特效配置加载失败:', id, err); return; } const node = new Node('vfx_' + id); node.parent = this.node; node.setPosition(0, 80, 0); const ps = node.addComponent(ParticleSystem2D); applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame); this.scheduleOnce(() => { ps.stopSystem(); }, 1.5); this.scheduleOnce(() => { node.destroy(); }, 5); }); } private makeButton(text: string, x: number, y: number, color: Color, onClick: () => void) { const node = new Node('btn_' + text); node.parent = this.node; node.setPosition(x, y, 0); const w = 150, h = 52; const ut = node.addComponent(UITransform); ut.setContentSize(w, h); const g = node.addComponent(Graphics); g.fillColor = color; this.roundRect(g, -w / 2, -h / 2, w, h, 12); g.fill(); this.makeLabel(node, text, 0, 0, 22, new Color(255, 255, 255, 255)); const btn = node.addComponent(Button); btn.transition = Button.Transition.SCALE; btn.zoomScale = 0.92; node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => onClick()); } private makeLabel(parent: Node, text: string, x: number, y: number, size: number, color?: Color) { const n = new Node('label'); n.parent = parent; n.setPosition(x, y, 0); const lab = n.addComponent(Label); lab.string = text; lab.fontSize = size; lab.lineHeight = size + 2; lab.color = color || new Color(60, 60, 60, 255); const ps = parent.scale; if (ps.x !== 0 && ps.x !== 1) n.setScale(1 / ps.x, 1 / ps.y, 1); } private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) { g.moveTo(x + r, y); g.lineTo(x + w - r, y); g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false); g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false); g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false); g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close(); } } """ # ---------------------------------------------------------------- 模板脚本:SlotGame.ts # __SYMBOLS__ 会被替换成本 game 的符号列表 SLOT_GAME_TS = r"""// ============================================================= // SlotGame.ts —— 果冻老虎机(可玩原型)by anim_studio(按本 game 资源生成) // 用法:把本脚本挂到 Canvas 下的一个空节点上,点播放。 // ============================================================= import { _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame, ParticleSystem2D, UITransform, Label, Graphics, Button, Color, Vec3, view, EventTouch, tween, profiler, } from 'cc'; import { applyParticleConfig } from './ParticleConfig'; const { ccclass } = _decorator; const SYMBOLS = __SYMBOLS__; const COLS = 5, ROWS = 3; const CELL = 118; const SYM_SCALE = 0.085; const BET = 50; const START_BALANCE = 1000; @ccclass('SlotGame') export class SlotGame extends Component { private dataMap: Record = {}; private cells: sp.Skeleton[][] = []; private cur: string[][] = []; private finalGrid: string[][] = []; private spinning = false; private elapsed = 0; private colStopAt: number[] = []; private colStopped: boolean[] = []; private swapTimer: number[] = []; private balance = START_BALANCE; private displayBalance = START_BALANCE; private balanceLabel!: Label; private winLabel!: Label; private spinBtn!: Node; private particleTex: SpriteFrame | null = null; private gridY = 30; private startX = -((COLS - 1) * CELL) / 2; onLoad() { profiler && profiler.hideStats(); this.node.setPosition(0, 0, 0); const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform); ut.setAnchorPoint(0.5, 0.5); const s = view.getVisibleSize(); ut.setContentSize(s.width, s.height); this.buildBackground(s.width, s.height); this.buildPanel(); this.buildHud(s.width, s.height); this.buildSpinButton(s.height); let left = SYMBOLS.length; resources.load('vfx/particle/spriteFrame', SpriteFrame, (_e, sf) => { if (sf) this.particleTex = sf; }); SYMBOLS.forEach((id) => { resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => { if (!err) this.dataMap[id] = data; if (--left === 0) this.buildGrid(); }); }); } private buildBackground(W: number, H: number) { const n = new Node('bg'); n.parent = this.node; n.setSiblingIndex(0); const g = n.addComponent(Graphics); g.fillColor = new Color(40, 24, 64, 255); g.rect(-W / 2, -H / 2, W, H); g.fill(); g.fillColor = new Color(58, 34, 92, 255); g.rect(-W / 2, H / 2 - 90, W, 90); g.fill(); const t = new Node('title'); t.parent = this.node; t.setPosition(0, H / 2 - 45, 0); const lab = t.addComponent(Label); lab.string = '🍬 JELLY SLOT 🍬'; lab.fontSize = 34; lab.lineHeight = 38; lab.color = new Color(255, 235, 160, 255); } private buildPanel() { const w = COLS * CELL + 30, h = ROWS * CELL + 30; const n = new Node('panel'); n.parent = this.node; n.setPosition(0, this.gridY, 0); const g = n.addComponent(Graphics); g.fillColor = new Color(24, 14, 38, 255); this.roundRect(g, -w / 2, -h / 2, w, h, 18); g.fill(); const tile = CELL - 12; for (let c = 0; c < COLS; c++) { for (let r = 0; r < ROWS; r++) { const cx = this.startX + c * CELL; const cy = (1 - r) * CELL; g.fillColor = new Color(247, 243, 252, 255); this.roundRect(g, cx - tile / 2, cy - tile / 2, tile, tile, 14); g.fill(); g.lineWidth = 3; g.strokeColor = new Color(255, 205, 110, 200); this.roundRect(g, cx - tile / 2, cy - tile / 2, tile, tile, 14); g.stroke(); } } g.lineWidth = 6; g.strokeColor = new Color(255, 200, 90, 255); this.roundRect(g, -w / 2, -h / 2, w, h, 18); g.stroke(); } private buildHud(W: number, H: number) { const b = new Node('balance'); b.parent = this.node; b.setPosition(-W / 2 + 130, H / 2 - 130, 0); this.balanceLabel = b.addComponent(Label); this.balanceLabel.fontSize = 26; this.balanceLabel.color = new Color(180, 240, 255, 255); const w = new Node('win'); w.parent = this.node; w.setPosition(0, H / 2 - 130, 0); this.winLabel = w.addComponent(Label); this.winLabel.fontSize = 30; this.winLabel.color = new Color(255, 220, 120, 255); this.winLabel.string = ''; this.refreshBalance(); } private refreshBalance() { this.balanceLabel.string = `💰 ${Math.floor(this.displayBalance)}`; } private buildSpinButton(H: number) { const node = new Node('spin'); node.parent = this.node; node.setPosition(0, -H / 2 + 70, 0); const w = 210, h = 64; node.addComponent(UITransform).setContentSize(w, h); const g = node.addComponent(Graphics); g.fillColor = new Color(255, 110, 70, 255); this.roundRect(g, -w / 2, -h / 2, w, h, 16); g.fill(); const ln = new Node('t'); ln.parent = node; const lab = ln.addComponent(Label); lab.string = '▶ SPIN'; lab.fontSize = 30; lab.color = new Color(255, 255, 255, 255); const btn = node.addComponent(Button); btn.transition = Button.Transition.SCALE; btn.zoomScale = 0.93; node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => this.spin()); this.spinBtn = node; } private buildGrid() { for (let c = 0; c < COLS; c++) { this.cells[c] = []; this.cur[c] = []; this.finalGrid[c] = []; for (let r = 0; r < ROWS; r++) { const id = this.rand(); const node = new Node(`cell_${c}_${r}`); node.parent = this.node; node.setPosition(this.startX + c * CELL, this.cellY(r), 0); node.setScale(SYM_SCALE, SYM_SCALE, 1); const sk = node.addComponent(sp.Skeleton); sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false; sk.setAnimation(0, 'idle', true); this.cells[c][r] = sk; this.cur[c][r] = id; } } } private cellY(r: number) { return this.gridY + (1 - r) * CELL; } private spin() { if (this.spinning) return; if (this.balance < BET) { this.flashWin('余额不足!'); return; } this.balance -= BET; this.displayBalance = this.balance; this.refreshBalance(); this.winLabel.string = ''; this.decideResult(); this.spinning = true; this.elapsed = 0; for (let c = 0; c < COLS; c++) { this.colStopped[c] = false; this.colStopAt[c] = 0.6 + c * 0.28; this.swapTimer[c] = 0; } this.setSpinEnabled(false); } private decideResult() { for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) this.finalGrid[c][r] = this.rand(); if (Math.random() < 0.4) { const r = Math.floor(Math.random() * ROWS); const sym = this.rand(); const len = Math.random() < 0.4 ? 4 : 3; for (let c = 0; c < len && c < COLS; c++) this.finalGrid[c][r] = sym; } } update(dt: number) { if (!this.spinning) { if (Math.abs(this.displayBalance - this.balance) > 0.5) { this.displayBalance += (this.balance - this.displayBalance) * Math.min(1, dt * 6); this.refreshBalance(); } return; } this.elapsed += dt; let allStopped = true; for (let c = 0; c < COLS; c++) { if (this.colStopped[c]) continue; if (this.elapsed >= this.colStopAt[c]) { for (let r = 0; r < ROWS; r++) this.setSymbol(c, r, this.finalGrid[c][r]); this.colStopped[c] = true; this.popColumn(c); } else { this.swapTimer[c] -= dt; if (this.swapTimer[c] <= 0) { this.swapTimer[c] = 0.06; for (let r = 0; r < ROWS; r++) this.setSymbol(c, r, this.rand()); } allStopped = false; } } if (allStopped) { this.spinning = false; this.evaluate(); this.setSpinEnabled(true); } } private setSymbol(c: number, r: number, id: string) { const sk = this.cells[c][r]; if (this.cur[c][r] !== id) { sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false; this.cur[c][r] = id; } sk.setAnimation(0, 'idle', true); } private popColumn(c: number) { for (let r = 0; r < ROWS; r++) { const n = this.cells[c][r].node; n.setScale(SYM_SCALE * 1.25, SYM_SCALE * 1.25, 1); tween(n).to(0.12, { scale: new Vec3(SYM_SCALE, SYM_SCALE, 1) }, { easing: 'backOut' }).start(); } } private evaluate() { let totalWin = 0; const winners: sp.Skeleton[] = []; for (let r = 0; r < ROWS; r++) { const first = this.finalGrid[0][r]; let count = 1; while (count < COLS && this.finalGrid[count][r] === first) count++; if (count >= 3) { totalWin += BET * (count - 2) * 2; for (let c = 0; c < count; c++) winners.push(this.cells[c][r]); } } if (totalWin > 0) { this.balance += totalWin; this.flashWin(`WIN +${totalWin}`); winners.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); }); this.playCoinRain(); } else { this.winLabel.string = ''; } } private flashWin(text: string) { this.winLabel.string = text; const n = this.winLabel.node; n.setScale(0.6, 0.6, 1); tween(n).to(0.35, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' }).start(); } private playCoinRain() { resources.load('vfx/coin_rain', JsonAsset, (err, asset) => { if (err) return; const n = new Node('coins'); n.parent = this.node; n.setPosition(0, 120, 0); const ps = n.addComponent(ParticleSystem2D); applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame); this.scheduleOnce(() => ps.stopSystem(), 1.6); this.scheduleOnce(() => n.destroy(), 5); }); } private rand() { return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]; } private setSpinEnabled(on: boolean) { const btn = this.spinBtn.getComponent(Button); if (btn) btn.interactable = on; this.spinBtn.getChildByName('t')!.getComponent(Label)!.string = on ? '▶ SPIN' : '转动中…'; } private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) { g.moveTo(x + r, y); g.lineTo(x + w - r, y); g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false); g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false); g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false); g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close(); } } """ # ---------------------------------------------------------------- 教程 def _tutorial_md(game): return f"""# 把「{game}」素材接进 Cocos —— 零基础教程 这个文件夹是你点「导出 Cocos 整合包」自动生成的,可以直接进 Cocos Creator。 全程不用写代码:装软件 → 新建项目 → 拖文件 → 挂一个脚本 → 点播放。 ## 包里有什么 ``` cocos-pack/ ├─ 本教程.md └─ assets/ ← 整个拖进 Cocos ├─ resources/characters/ 角色三件套(.json/.atlas/.png) ├─ resources/vfx/ 粒子配置 .json + particle.png └─ scripts/ JellyDemo.ts / ParticleConfig.ts / TweenPresets.ts ``` > `resources` 文件夹名不能改,Cocos 靠它动态加载资源。 ## 步骤 1. 装 **Cocos Creator 3.8.x**:https://www.cocos.com/creator-download 2. 新建一个 **空项目(Empty / 2D)** 并打开。 3. 把本包 `assets/` 里的 **resources、scripts 两个文件夹**一起拖进 Cocos 资源管理器的 `assets` 上,等导入进度条走完。 4. 在 `assets` 右键 → 新建 Scene,双击打开;确保有个 **Canvas** 节点,在它下面新建一个**空节点**。 5. 把 `scripts/JellyDemo.ts` 拖到那个空节点的属性检查器上(或「添加组件 → JellyDemo」)。 6. 点正上方 **▶ 播放**:角色排队果冻抖动,底部按钮可触发「全部 WIN」和各粒子特效。 ## 常见问题 - 角色没出来:多半素材没导完或 resources 被改名;确认能在资源管理器把角色展开成 SkeletonData。 - 角色发黑/白边:脚本已设 premultipliedAlpha=false;仍有则在贴图设置关掉 PremultiplyAlpha 重新导入。 - 太大/太小:改 `JellyDemo.ts` 顶部 `CHAR_SCALE`。 - 特效看不见:确认 `resources/vfx/particle.png` 在。 """ # ---------------------------------------------------------------- 主函数 def export(game, out_root, log=print): base = os.path.join(out_root, game) if not os.path.isfile(os.path.join(base, "library.json")): raise FileNotFoundError(f"找不到 game「{game}」的 library.json") lib = json.load(open(os.path.join(base, "library.json"), encoding="utf-8")) boss = lib.get("slot_config", {}).get("boss", {}) if boss.get("enabled"): boss_id = boss.get("id") or "boss_demon_lord" lib_char_ids = {c.get("id") for c in lib.get("characters", [])} missing_files = [ f"characters/{boss_id}.{ext}" for ext in ("json", "atlas", "png") if not os.path.isfile(os.path.join(base, "characters", f"{boss_id}.{ext}")) ] if boss_id not in lib_char_ids or missing_files: raise RuntimeError( f"当前资源库缺少关主大魔王资源:{boss_id}。请先在角色库任务卡片里补生成该资源," "成功后才会包含 idle/watch/coin_throw/taunt/stomp/explode 等动作。" ) pack = os.path.join(base, "cocos-pack") if os.path.exists(pack): shutil.rmtree(pack) res_ch = os.path.join(pack, "assets", "resources", "characters") res_vfx = os.path.join(pack, "assets", "resources", "vfx") scripts = os.path.join(pack, "assets", "scripts") for d in (res_ch, res_vfx, scripts): os.makedirs(d, exist_ok=True) # 角色三件套 char_ids = [] src_ch = os.path.join(base, "characters") if os.path.isdir(src_ch): for f in sorted(os.listdir(src_ch)): shutil.copy2(os.path.join(src_ch, f), os.path.join(res_ch, f)) if f.endswith(".json"): char_ids.append(f[:-5]) log(f"📦 角色 {len(char_ids)} 个") # 粒子配置:去掉 .particle 后缀,路径更干净 vfx_ids = [] src_vfx = os.path.join(base, "vfx") if os.path.isdir(src_vfx): for f in sorted(os.listdir(src_vfx)): if f.endswith(".particle.json"): vid = f[: -len(".particle.json")] shutil.copy2(os.path.join(src_vfx, f), os.path.join(res_vfx, vid + ".json")) vfx_ids.append(vid) elif f.endswith(".json"): shutil.copy2(os.path.join(src_vfx, f), os.path.join(res_vfx, f)) vfx_ids.append(f[:-5]) log(f"📦 特效 {len(vfx_ids)} 个") # UI 美术(背景 / 外框 / 按钮 / Logo 等整图) src_art = os.path.join(base, "ui_art") art_ids = [] if os.path.isdir(src_art): res_art = os.path.join(pack, "assets", "resources", "ui_art") os.makedirs(res_art, exist_ok=True) for f in sorted(os.listdir(src_art)): if f.endswith(".png"): shutil.copy2(os.path.join(src_art, f), os.path.join(res_art, f)) art_ids.append(f[:-4]) log(f"📦 UI 美术 {len(art_ids)} 张" if art_ids else "📦 无 UI 美术") # 粒子贴图 if _write_particle_png(os.path.join(res_vfx, "particle.png")): log("📦 粒子贴图 particle.png 已生成") else: log("⚠️ 未装 Pillow,未能生成 particle.png(特效会发射但看不见)") # 脚本 demo = (JELLY_DEMO_TS .replace("__CHARACTERS__", json.dumps(char_ids, ensure_ascii=False)) .replace("__VFX__", json.dumps(vfx_ids, ensure_ascii=False))) open(os.path.join(scripts, "JellyDemo.ts"), "w", encoding="utf-8").write(demo) open(os.path.join(scripts, "ParticleConfig.ts"), "w", encoding="utf-8").write(PARTICLE_CONFIG_TS) slot_tmpl = os.path.join(HERE, "templates", "SlotGame.ts") slot_src = open(slot_tmpl, encoding="utf-8").read() if os.path.isfile(slot_tmpl) else SLOT_GAME_TS slot_config = lib.get("slot_config") or {} configured_symbols = [s.get("id") for s in (slot_config.get("symbols") or []) if s.get("id")] slot_symbol_ids = [sid for sid in configured_symbols if sid in char_ids] or char_ids symbol_fit, symbol_fit_default = _symbol_fit_from_library(lib) game_config_ts = ( "export const SLOT_CONFIG = " + json.dumps(slot_config, ensure_ascii=False, indent=2) + " as const;\n" + "export const SYMBOLS = " + json.dumps(slot_symbol_ids, ensure_ascii=False, indent=2) + " as const;\n" + "export const SYMBOL_FIT = " + json.dumps(symbol_fit, ensure_ascii=False, indent=2) + " as const;\n" ) open(os.path.join(scripts, "GameConfig.ts"), "w", encoding="utf-8").write(game_config_ts) open(os.path.join(pack, "SlotMathReport.md"), "w", encoding="utf-8").write(_math_report_md(slot_config)) slot = (slot_src .replace("__SYMBOLS__", json.dumps(slot_symbol_ids, ensure_ascii=False)) .replace("__GAME_CONFIG__", json.dumps(slot_config, ensure_ascii=False)) .replace("__SYMBOL_FIT__", json.dumps(symbol_fit, ensure_ascii=False)) .replace("__SYMBOL_FIT_DEFAULT__", json.dumps(symbol_fit_default, ensure_ascii=False))) open(os.path.join(scripts, "SlotGame.ts"), "w", encoding="utf-8").write(slot) qa = _qa_report(lib, base, slot_src) open(os.path.join(pack, "QAReport.json"), "w", encoding="utf-8").write( json.dumps(qa, ensure_ascii=False, indent=2) ) open(os.path.join(pack, "QAReport.md"), "w", encoding="utf-8").write(_qa_report_md(qa)) if qa["summary"]["error"] or qa["summary"]["warning"]: log(f"🧪 QA:{qa['summary']['error']} 个错误,{qa['summary']['warning']} 个警告;详见 QAReport.md") for item in qa["items"][:6]: if item["level"] in ("error", "warning"): log(f"🧪 [{item['level']}] {item['target']} · {item['message']}") else: log("🧪 QA:通过,未发现错误或警告") src_tween = os.path.join(base, "ui", "TweenPresets.ts") if os.path.isfile(src_tween): shutil.copy2(src_tween, os.path.join(scripts, "TweenPresets.ts")) log("📦 脚本 JellyDemo.ts / SlotGame.ts / GameConfig.ts / ParticleConfig.ts / TweenPresets.ts 已写入") # 教程 open(os.path.join(pack, "把素材接进Cocos-零基础教程.md"), "w", encoding="utf-8").write(_tutorial_md(game)) log(f"✅ 整合包完成:out/{game}/cocos-pack/") return pack if __name__ == "__main__": import sys g = sys.argv[1] if len(sys.argv) > 1 else "game" export(g, os.path.join(HERE, "out"))