| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698 |
- """把某个 game 的产物打包成「可直接拖进 Cocos 的整合包」。
- 被 server.py 的 /api/export 调用,也可命令行单独跑:
- python exporter.py jelly-candy-slot
- 产物在 out/<game>/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
- # 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 _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<string, sp.SkeletonData> = {};
- 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"))
- 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)
- 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"))
|