// ============================================================= // SlotGame.ts —— 《果冻消消乐 · Jelly Pop》消除式老虎机(可玩原型) // by anim_studio // v4:先预加载全部 UI 美术,再搭界面——HUD 胶囊用 hud_pill、 // 圆按钮用 btn_round、SPIN 用 btn_spin;隐藏预览性能面板; // 4×5 大符号 + 每格圆角卡片。 // ============================================================= import { _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame, Sprite, ParticleSystem2D, UITransform, Label, Graphics, Button, Color, Vec3, view, EventTouch, tween, UIOpacity, } from 'cc'; import * as cc from 'cc'; import { applyParticleConfig } from './ParticleConfig'; const { ccclass } = _decorator; const SYMBOLS = __SYMBOLS__; const GENERATED_CONFIG = __GAME_CONFIG__; // ============ 画面结构(先定区域,再往里填元素)============ // 所有数值都是相对屏幕的比例;cy = 区域中心相对屏幕中心的 Y(向上为正)。 const LAYOUT = (GENERATED_CONFIG.layout || { logo: { cy: 0.41, maxW: 0.82, maxH: 0.15 }, // 顶部 Logo reel: { cy: 0.02, h: 0.60, aspect: 0.652, // 卷轴框(按新图:霓虹边+白板) holeW: 0.86, holeH: 0.92, cols: 4, rows: 6 }, // 白板内孔 + 网格行列 hud: { cy: -0.33, pillH: 0.40 }, // 三个 HUD 胶囊(按图比例) controls: { cy: -0.405, spinR: 0.13, smallR: 0.075, // 控制按钮(按宽度定标、做大) xMinus: 0.24, xTurbo: 0.40 }, }); const ECONOMY = GENERATED_CONFIG.economy || { startingBalance: 5000, defaultBet: 50 }; const WIN_RULES = GENERATED_CONFIG.winRules || { minMatch: 4 }; const FEATURE_RULES = GENERATED_CONFIG.features || {}; const FEEDBACK_CONFIG = GENERATED_CONFIG.feedback || {}; const PAYTABLE = GENERATED_CONFIG.paytable || {}; const SYMBOL_RULES = GENERATED_CONFIG.symbols || []; const MATH_MODEL = GENERATED_CONFIG.mathModel || {}; const PAYOUT_SCALE = MATH_MODEL.payoutScale ?? 1; const REEL_STRIPS = MATH_MODEL.reelStrips || []; // ============ 文字样式表(统一定义,按角色取用)============ // size<=1 表示相对所在容器高度的比例;否则按像素。ow=描边宽。 const STYLE = { hudTitle: { size: 0.30, color: [205, 222, 255], bold: false, outline: [40, 28, 78], ow: 0, letter: 0.04 }, hudValue: { size: 0.52, color: [255, 255, 255], bold: true, outline: [54, 30, 96], ow: 3 }, mult: { size: 66, color: [255, 214, 70], bold: true, outline: [188, 70, 24], ow: 6 }, floatWin: { size: 46, color: [255, 244, 130], bold: true, outline: [196, 92, 24], ow: 5 }, bigWin: { size: 0.16, color: [255, 224, 70], bold: true, outline: [180, 50, 16], ow: 9 }, // 报幕(相对屏宽) }; // ============ 每个符号的"按内容定标"参数(s=缩放/格, oyf=居中下移/格)============ // 由素材实际内容包围盒算出,保证果冻填满格子、垂直居中——这就是"按规格画/摆"。 const SYMFIT: Record = __SYMBOL_FIT__; const SYMFIT_DEFAULT = __SYMBOL_FIT_DEFAULT__; // ============ 交互/动效反馈(统一定义)============ const FEEDBACK = { btnPress: 0.86, btnBack: 0.16, idleBob: 0.016, dropFrom: 1.05, dropTime: FEEDBACK_CONFIG.dropTimeSec ?? 0.30, clearPop: FEEDBACK_CONFIG.clearPopScale ?? 1.30, spinDuration: FEEDBACK_CONFIG.spinDurationSec ?? 0.45, bigWinThresholdBet: FEEDBACK_CONFIG.bigWinThresholdBet ?? 25, screenShake: FEEDBACK_CONFIG.screenShake ?? false, }; const COLS = LAYOUT.reel.cols, ROWS = LAYOUT.reel.rows; const MIN_MATCH = WIN_RULES.minMatch ?? 4; const CASCADE_RULES = FEATURE_RULES.cascades || {}; const MAX_CASCADE = CASCADE_RULES.maxCascades ?? 6; const CASCADE_MULT_STEP = CASCADE_RULES.multiplierStep ?? 1; const CASCADE_MULT_MAX = CASCADE_RULES.maxMultiplier ?? 6; const START_BALANCE = ECONOMY.startingBalance ?? 5000; const DEFAULT_BET = ECONOMY.defaultBet ?? 50; const SCATTER_RULES = FEATURE_RULES.scatterFreeSpins || {}; const HOLD_RULES = FEATURE_RULES.holdAndWin || {}; const IS_CLUSTER = WIN_RULES.evaluation === 'cluster_count'; const FREE_SPIN_TRIGGER = SCATTER_RULES.triggerCount ?? 3; const FREE_SPIN_AWARD = SCATTER_RULES.awardSpins ?? 8; const HOLD_TRIGGER = HOLD_RULES.triggerCount ?? 6; const HOLD_RESPINS = HOLD_RULES.respins ?? 3; const SYMBOL_ROLE: Record = {}; SYMBOL_RULES.forEach((s: any) => { SYMBOL_ROLE[s.id] = s.role; }); @ccclass('SlotGame') export class SlotGame extends Component { private dataMap: Record = {}; private art: Record = {}; private cells: sp.Skeleton[][] = []; private ids: string[][] = []; private cell = 90; private gridX0 = 0; private gridY0 = 0; private spinning = false; private balance = START_BALANCE; private displayBalance = START_BALANCE; private bet = DEFAULT_BET; private multiplier = 1; private roundWin = 0; private cascade = 0; private freeSpins = 0; private holdActive = false; private holdRespinsLeft = 0; private holdHeld: boolean[][] = []; private holdWinTotal = 0; private balanceLabel!: Label; private betLabel!: Label; private winLabel!: Label; private multLabel!: Label; private spinBtn!: Node; private particleTex: SpriteFrame | null = null; private frameCY = 0; private frameW = 0; private frameH = 0; private W = 0; private H = 0; private symBaseY: number[][] = []; private t = 0; private turbo = false; private auto = false; private turboBtn!: Node; private autoBtn!: Node; onLoad() { try { (cc as any).profiler?.hideStats?.(); } catch (e) {} 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(); this.W = s.width; this.H = s.height; ut.setContentSize(s.width, s.height); this.computeLayout(s.width, s.height); this.buildBackground(s.width, s.height); // 渐变兜底 resources.load('vfx/particle/spriteFrame', SpriteFrame, (_e, sf) => { if (sf) this.particleTex = sf; }); const artIds = ['bg_main', 'reel_frame', 'logo', 'hud_pill', 'btn_round', 'btn_spin', 'btn_plus', 'btn_minus', 'btn_turbo', 'btn_auto', 'coin']; let toLoad = artIds.length; artIds.forEach((id) => { resources.load(`ui_art/${id}/spriteFrame`, SpriteFrame, (err, sf) => { this.art[id] = err ? null : sf; if (--toLoad === 0) this.afterArt(); }); }); } private afterArt() { const W = this.W, H = this.H; this.buildArt(W, H); this.buildHud(W, H); this.buildControls(W, H); let left = SYMBOLS.length; SYMBOLS.forEach((id) => { resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => { if (!err) this.dataMap[id] = data; if (--left === 0) this.buildGrid(); }); }); } private computeLayout(W: number, H: number) { const R = LAYOUT.reel; let fh = H * R.h; let fw = fh * R.aspect; if (fw > W * 0.96) { fw = W * 0.96; fh = fw / R.aspect; } this.frameW = fw; this.frameH = fh; this.frameCY = H * R.cy; const innerW = fw * R.holeW, innerH = fh * R.holeH; this.cell = Math.min(innerW / COLS, innerH / ROWS); this.gridX0 = -((COLS - 1) * this.cell) / 2; this.gridY0 = this.frameCY + ((ROWS - 1) * this.cell) / 2; } private cellPos(c: number, r: number): [number, number] { return [this.gridX0 + c * this.cell, this.gridY0 - r * this.cell]; } 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(120, 200, 255, 255); g.rect(-W / 2, 0, W, H / 2); g.fill(); g.fillColor = new Color(255, 150, 175, 255); g.rect(-W / 2, -H / 2, W, H / 2); g.fill(); } // ---- 背景大图 / 卷轴框 / Logo(有图用图,没图兜底)---- private buildArt(W: number, H: number) { if (this.art['bg_main']) { const [w, h] = this.coverSize(this.art['bg_main']!, W, H); this.spriteNode(this.art['bg_main']!, w, h, 0, 0, 1); } if (this.art['reel_frame']) { this.spriteNode(this.art['reel_frame']!, this.frameW, this.frameH, 0, this.frameCY, 3); } else { const n = new Node('frame'); n.parent = this.node; n.setPosition(0, this.frameCY, 0); n.setSiblingIndex(3); const g = n.addComponent(Graphics); g.lineWidth = 10; g.strokeColor = new Color(120, 180, 255, 255); this.roundRect(g, -this.frameW / 2, -this.frameH / 2, this.frameW, this.frameH, 26); g.stroke(); } const logoCy = H * LAYOUT.logo.cy; if (this.art['logo']) { const [w, h] = this.fitSize(this.art['logo']!, W * LAYOUT.logo.maxW, H * LAYOUT.logo.maxH); this.spriteNode(this.art['logo']!, w, h, 0, logoCy, 21); } else { const t = new Node('logo'); t.parent = this.node; t.setPosition(0, logoCy, 0); t.setSiblingIndex(21); const lab = t.addComponent(Label); lab.string = '🍬 JELLY POP'; lab.fontSize = 42; lab.color = new Color(255, 90, 70, 255); } } // ---------------- HUD:用 hud_pill 美术 ---------------- private buildHud(W: number, H: number) { const y = H * LAYOUT.hud.cy; const pw = (W - 36) / 3 - 8, gap = 8; const ps = this.art['hud_pill']; const ph = ps ? pw * (ps.rect.height / ps.rect.width) : pw * LAYOUT.hud.pillH; // 按图比例,不拉伸 this.balanceLabel = this.makePill(-(pw + gap), y, pw, ph, '余额', `${START_BALANCE}`); this.betLabel = this.makePill(0, y, pw, ph, '下注', `${this.bet}`); this.winLabel = this.makePill(pw + gap, y, pw, ph, '本局赢', '0'); const m = new Node('mult'); m.parent = this.node; m.setPosition(0, this.frameCY, 0); m.setSiblingIndex(25); this.multLabel = m.addComponent(Label); this.applyStyle(this.multLabel, STYLE.mult, 0); this.multLabel.string = ''; } private makePill(x: number, y: number, w: number, h: number, title: string, val: string): Label { const n = new Node('pill'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(16); if (this.art['hud_pill']) { const s = n.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = this.art['hud_pill']!; } else { const g = n.addComponent(Graphics); g.fillColor = new Color(70, 50, 120, 235); this.roundRect(g, -w / 2, -h / 2, w, h, h / 2); g.fill(); } const ut = n.getComponent(UITransform) || n.addComponent(UITransform); ut.setContentSize(w, h); // 标题(小字,靠左) const tl = new Node('t'); tl.parent = n; tl.setPosition(-w * 0.30, 0, 0); const tlab = tl.addComponent(Label); tlab.string = title; this.applyStyle(tlab, STYLE.hudTitle, h); // 数值(大字带描边,靠右) const vl = new Node('v'); vl.parent = n; vl.setPosition(w * 0.12, 0, 0); const lab = vl.addComponent(Label); lab.string = val; this.applyStyle(lab, STYLE.hudValue, h); return lab; } // 统一文字样式 private applyStyle(lab: Label, st: any, base: number) { lab.fontSize = st.size <= 1 ? Math.round(base * st.size) : st.size; lab.lineHeight = lab.fontSize; lab.color = new Color(st.color[0], st.color[1], st.color[2], 255); if (st.bold) lab.isBold = true; if (st.ow && st.ow > 0) { lab.enableOutline = true; lab.outlineColor = new Color(st.outline[0], st.outline[1], st.outline[2], 255); lab.outlineWidth = st.ow; } } // 数字变化时弹一下 private pop(node: Node, s = 1.22) { node.setScale(s, s, 1); tween(node).to(0.22, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }).start(); } // 中奖时在网格中央飘一个 +N 上升淡出 private floatWin(amount: number) { const n = new Node('fw'); n.parent = this.node; n.setPosition(0, this.frameCY + this.cell, 0); n.setSiblingIndex(27); const lab = n.addComponent(Label); lab.string = '+' + amount; this.applyStyle(lab, STYLE.floatWin, 0); const op = n.addComponent(UIOpacity); n.setScale(0.5, 0.5, 1); tween(n).to(0.16, { scale: new Vec3(1.15, 1.15, 1) }, { easing: 'backOut' }) .to(0.12, { scale: new Vec3(1, 1, 1) }).start(); tween(n).by(0.85, { position: new Vec3(0, this.cell * 1.6, 0) }, { easing: 'cubicOut' }).start(); tween(op).delay(0.35).to(0.5, { opacity: 0 }).call(() => n.destroy()).start(); } // ---------------- 控制区:btn_round / btn_spin 美术 ---------------- private buildControls(W: number, H: number) { const C = LAYOUT.controls; const y = H * C.cy; const big = W * C.spinR; // SPIN 按屏宽定标,最突出 const sr = W * C.smallR; this.spinBtn = this.makeBtn(0, y, big, 'btn_spin', '⟳', big * 0.6, () => this.spin()); this.turboBtn = this.makeBtn(-W * C.xTurbo, y, sr, 'btn_turbo', '⚡', sr * 0.8, () => this.toggleTurbo()); this.makeBtn(-W * C.xMinus, y, sr, 'btn_minus', '−', sr, () => this.changeBet(-10)); this.makeBtn(W * C.xMinus, y, sr, 'btn_plus', '+', sr, () => this.changeBet(10)); this.autoBtn = this.makeBtn(W * C.xTurbo, y, sr, 'btn_auto', '▶', sr * 0.8, () => this.toggleAuto()); } private makeBtn(x: number, y: number, r: number, artId: string, glyph: string, fs: number, onClick: () => void): Node { const node = new Node('btn'); node.parent = this.node; node.setPosition(x, y, 0); node.setSiblingIndex(17); let hasArt = false; if (this.art[artId]) { const s = node.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = this.art[artId]!; hasArt = true; } else { const g = node.addComponent(Graphics); g.fillColor = new Color(90, 70, 140, 255); g.circle(0, 0, r); g.fill(); g.lineWidth = 4; g.strokeColor = new Color(255, 255, 255, 235); g.circle(0, 0, r); g.stroke(); } const ut = node.getComponent(UITransform) || node.addComponent(UITransform); ut.setContentSize(r * 2, r * 2); // 有美术(图标已烤进图里)就不叠字;没有才用文字兜底 if (!hasArt) { const gl = new Node('g'); gl.parent = node; const lab = gl.addComponent(Label); lab.string = glyph; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 255); } const btn = node.addComponent(Button); btn.transition = Button.Transition.NONE; // 自己控制反馈,更跟手 const press = () => { tween(node).to(0.07, { scale: new Vec3(FEEDBACK.btnPress, FEEDBACK.btnPress, 1) }).start(); }; const release = () => { tween(node).to(FEEDBACK.btnBack, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }).start(); }; node.on(Node.EventType.TOUCH_START, press); node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => { release(); onClick(); }); node.on(Node.EventType.TOUCH_CANCEL, release); return node; } private makeTextLabel(x: number, y: number, text: string, fs: number) { const n = new Node('tl'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(17); const lab = n.addComponent(Label); lab.string = text; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 235); } private changeBet(d: number) { if (this.spinning) return; this.bet = Math.max(10, Math.min(500, this.bet + d)); this.betLabel.string = `${this.bet}`; this.pop(this.betLabel.node); } // TURBO:加速;AUTO:自动连转。带高亮状态。 private td(v: number) { return this.turbo ? v * 0.4 : v; } private toggleTurbo() { this.turbo = !this.turbo; this.tintBtn(this.turboBtn, this.turbo); } private toggleAuto() { this.auto = !this.auto; this.tintBtn(this.autoBtn, this.auto); if (this.auto && !this.spinning) this.spin(); } private tintBtn(node: Node, on: boolean) { const s = node.getComponent(Sprite); if (s) s.color = on ? new Color(255, 224, 90, 255) : new Color(255, 255, 255, 255); tween(node).to(0.12, { scale: new Vec3(on ? 1.12 : 1, on ? 1.12 : 1, 1) }, { easing: 'backOut' }).start(); } // 大额报幕 + 金币雨 private celebrate(amount: number) { const n = new Node('bigwin'); n.parent = this.node; n.setPosition(0, this.frameCY, 0); n.setSiblingIndex(this.node.children.length); // 强制置顶 const lab = n.addComponent(Label); lab.string = '+' + amount; this.applyStyle(lab, STYLE.bigWin, this.W); const op = n.addComponent(UIOpacity); n.setScale(0.2, 0.2, 1); tween(n).to(0.28, { scale: new Vec3(1.18, 1.18, 1) }, { easing: 'backOut' }) .to(0.12, { scale: new Vec3(1, 1, 1) }) .delay(0.6).to(0.25, { scale: new Vec3(1.25, 1.25, 1) }).start(); tween(op).delay(0.95).to(0.35, { opacity: 0 }).call(() => n.destroy()).start(); this.coinShower(Math.max(12, Math.min(40, Math.floor(amount / 15)))); } private coinShower(count: number) { const sf = this.art['coin']; if (!sf) return; const sz = this.cell * 0.6; for (let i = 0; i < count; i++) { const x0 = (Math.random() * 2 - 1) * this.frameW * 0.42; const c = this.spriteNode(sf, sz, sz, x0, this.H * 0.5 + this.cell, this.node.children.length + 1); const op = c.addComponent(UIOpacity); const dur = 0.9 + Math.random() * 0.7; const dx = (Math.random() * 2 - 1) * this.W * 0.12; tween(c).delay(Math.random() * 0.35) .by(dur, { position: new Vec3(dx, -this.H - this.cell * 2, 0), angle: (Math.random() * 2 - 1) * 720 }, { easing: 'sineIn' }) .call(() => c.destroy()).start(); tween(op).delay(dur * 0.7).to(dur * 0.3, { opacity: 0 }).start(); } } private fitSize(sf: SpriteFrame, boxW: number, boxH: number): [number, number] { const r = sf.rect; const aw = r.width || 1, ah = r.height || 1; const s = Math.min(boxW / aw, boxH / ah); return [aw * s, ah * s]; } private coverSize(sf: SpriteFrame, boxW: number, boxH: number): [number, number] { const r = sf.rect; const aw = r.width || 1, ah = r.height || 1; const s = Math.max(boxW / aw, boxH / ah); return [aw * s, ah * s]; } private spriteNode(sf: SpriteFrame, w: number, h: number, x: number, y: number, sib: number): Node { const n = new Node('art'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(sib); const s = n.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = sf; const ut = n.getComponent(UITransform) || n.addComponent(UITransform); ut.setContentSize(w, h); return n; } // ---------------- 网格:圆角卡片 + 大符号 ---------------- private fit(id: string) { return SYMFIT[id] || SYMFIT_DEFAULT; } private buildGrid() { const cardSize = this.cell * 0.92; for (let c = 0; c < COLS; c++) { this.cells[c] = []; this.ids[c] = []; this.symBaseY[c] = []; for (let r = 0; r < ROWS; r++) { const id = this.reelRand(c); const [x, y] = this.cellPos(c, r); const card = new Node(`card_${c}_${r}`); card.parent = this.node; card.setPosition(x, y, 0); card.setSiblingIndex(5); const cg = card.addComponent(Graphics); cg.fillColor = new Color(255, 255, 255, 215); this.roundRect(cg, -cardSize / 2, -cardSize / 2, cardSize, cardSize, cardSize * 0.24); cg.fill(); cg.lineWidth = 3; cg.strokeColor = new Color(120, 175, 255, 240); this.roundRect(cg, -cardSize / 2, -cardSize / 2, cardSize, cardSize, cardSize * 0.24); cg.stroke(); const f = this.fit(id); const scale = this.cell * f.s; const baseY = y - this.cell * f.oyf; this.symBaseY[c][r] = baseY; const node = new Node(`cell_${c}_${r}`); node.parent = this.node; node.setSiblingIndex(6); node.setPosition(x, baseY, 0); node.setScale(scale, 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.ids[c][r] = id; } } } private setSym(c: number, r: number, id: string) { const sk = this.cells[c][r]; sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false; sk.setAnimation(0, 'idle', true); this.ids[c][r] = id; const [x, y] = this.cellPos(c, r); const f = this.fit(id); const scale = this.cell * f.s; const baseY = y - this.cell * f.oyf; this.symBaseY[c][r] = baseY; const nd = sk.node; nd.setScale(scale, scale, 1); nd.setPosition(x, baseY + this.cell * FEEDBACK.dropFrom, 0); // 从上方落入 tween(nd).to(FEEDBACK.dropTime, { position: new Vec3(x, baseY, 0) }, { easing: 'backOut' }).start(); } private spin() { if (this.spinning) return; if (this.holdActive) { this.holdRespin(); return; } const freeSpin = this.freeSpins > 0; if (!freeSpin && this.balance < this.bet) { this.flashMult('余额不足'); return; } if (freeSpin) { this.freeSpins -= 1; this.flashMult(`免费旋转 ${this.freeSpins}`); } else { this.balance -= this.bet; this.displayBalance = this.balance; this.balanceLabel.string = `${Math.floor(this.displayBalance)}`; this.pop(this.balanceLabel.node); } this.multiplier = 1; this.roundWin = 0; this.cascade = 0; this.winLabel.string = '0'; this.multLabel.string = ''; this.spinning = true; this.setSpinEnabled(false); tween(this.spinBtn).by(FEEDBACK.spinDuration, { angle: -360 }, { easing: 'cubicOut' }).start(); // 联动旋转 for (let c = 0; c < COLS; c++) { const strip = this.reel(c); const start = Math.floor(Math.random() * strip.length); for (let r = 0; r < ROWS; r++) this.setSym(c, r, strip[(start + r) % strip.length]); } this.scheduleOnce(() => this.resolve(), this.td(0.45)); } private resolve() { if (this.cascade === 0) this.resolveFeatureTriggers(); const count: Record = {}; for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) count[this.ids[c][r]] = (count[this.ids[c][r]] || 0) + 1; const result = IS_CLUSTER ? this.findClusterWins() : this.findCountWins(count); if (result.groups.length === 0) { this.endRound(); return; } let cleared = 0; const winners: sp.Skeleton[] = []; for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) { if (result.mask[c] && result.mask[c][r]) { winners.push(this.cells[c][r]); cleared++; } } const pay = this.calcPay(result.groups); this.roundWin += pay; this.balance += pay; this.winLabel.string = `${this.roundWin}`; this.pop(this.winLabel.node, 1.3); this.floatWin(pay); this.flashMult(`x${this.multiplier} +${pay}`); winners.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); const nd = sk.node; const sc = nd.scale.x; tween(nd).to(0.1, { scale: new Vec3(sc * FEEDBACK.clearPop, sc * FEEDBACK.clearPop, 1) }) .to(0.14, { scale: new Vec3(sc, sc, 1) }, { easing: 'backOut' }).start(); }); this.playParticle('coin_rain'); this.cascade += 1; this.scheduleOnce(() => { for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) if (result.mask[c] && result.mask[c][r]) this.setSym(c, r, this.reelRand(c)); this.multiplier = Math.min(CASCADE_MULT_MAX, this.multiplier + CASCADE_MULT_STEP); if (this.cascade >= MAX_CASCADE) { this.endRound(); return; } this.scheduleOnce(() => this.resolve(), this.td(0.4)); }, this.td(0.6)); } private calcPay(groups: {id: string; n: number}[]) { let total = 0; groups.forEach(({id, n}) => { const table = PAYTABLE[id] || {}; const capped = Math.min(6, Math.max(MIN_MATCH, n)); const mult = Number(table[String(capped)] ?? table[String(MIN_MATCH)] ?? capped); total += mult * this.bet; }); return Math.max(1, Math.floor(total * this.multiplier * PAYOUT_SCALE)); } private resolveFeatureTriggers() { if (SCATTER_RULES.enabled) { const scatterCount = this.countRole('scatter'); if (scatterCount >= FREE_SPIN_TRIGGER) { const scatterPay = this.featurePay('scatter', scatterCount); this.freeSpins += FREE_SPIN_AWARD; if (scatterPay > 0) { this.roundWin += scatterPay; this.balance += scatterPay; this.winLabel.string = `${this.roundWin}`; } this.flashMult(`FREE SPINS +${FREE_SPIN_AWARD}`); this.playParticle('bigwin_glow'); } } if (HOLD_RULES.enabled && !this.holdActive && this.countRole('bonus') >= HOLD_TRIGGER) { this.startHoldAndWin(); } } private findCountWins(count: Record) { const wildCount = this.countRole('wild'); const mask = this.emptyMask(); const groups = Object.keys(count).filter((id) => { if (this.isWild(id) || this.isScatter(id) || this.isBonus(id)) return false; return (count[id] || 0) + wildCount >= MIN_MATCH; }).map((id) => ({id, n: (count[id] || 0) + wildCount})); for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) { const id = this.ids[c][r]; if (groups.some((g) => g.id === id) || (this.isWild(id) && groups.length > 0)) mask[c][r] = true; } return {groups, mask}; } private findClusterWins() { const groups: {id: string; n: number}[] = []; const mask = this.emptyMask(); const regular = SYMBOLS.filter((id: string) => !this.isWild(id) && !this.isScatter(id) && !this.isBonus(id)); regular.forEach((sid: string) => { const seen = this.emptyMask(); for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) { if (seen[c][r] || (this.ids[c][r] !== sid && !this.isWild(this.ids[c][r]))) continue; const stack: [number, number][] = [[c, r]]; const cells: [number, number][] = []; seen[c][r] = true; while (stack.length) { const [x, y] = stack.pop()!; cells.push([x, y]); [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]].forEach(([nx, ny]) => { if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS || seen[nx][ny]) return; const nid = this.ids[nx][ny]; if (nid === sid || this.isWild(nid)) { seen[nx][ny] = true; stack.push([nx, ny]); } }); } if (cells.length >= MIN_MATCH) { groups.push({id: sid, n: cells.length}); cells.forEach(([x, y]) => { mask[x][y] = true; }); } } }); return {groups, mask}; } private emptyMask() { const mask: boolean[][] = []; for (let c = 0; c < COLS; c++) { mask[c] = []; for (let r = 0; r < ROWS; r++) mask[c][r] = false; } return mask; } private featurePay(id: string, count: number) { const table = PAYTABLE[id] || {}; const n = Math.min(6, Math.max(3, count)); return Math.floor(Number(table[String(n)] ?? 0) * this.bet * PAYOUT_SCALE); } private role(id: string) { return SYMBOL_ROLE[id] || ''; } private isWild(id: string) { return this.role(id) === 'wild' || id === 'wild'; } private isScatter(id: string) { return this.role(id) === 'scatter' || id.indexOf('scatter') >= 0; } private isBonus(id: string) { return this.role(id) === 'bonus' || id.indexOf('coin') >= 0 || id === 'collect'; } private countRole(role: string) { let n = 0; for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) { const id = this.ids[c][r]; if ((role === 'wild' && this.isWild(id)) || (role === 'scatter' && this.isScatter(id)) || (role === 'bonus' && this.isBonus(id))) n++; } return n; } private startHoldAndWin() { this.holdActive = true; this.holdRespinsLeft = HOLD_RESPINS; this.holdWinTotal = 0; this.holdHeld = []; for (let c = 0; c < COLS; c++) { this.holdHeld[c] = []; for (let r = 0; r < ROWS; r++) { const held = this.isBonus(this.ids[c][r]); this.holdHeld[c][r] = held; if (held) this.holdWinTotal += this.coinValue(c, r); } } this.flashMult(`HOLD & WIN ${this.holdRespinsLeft}`); this.playParticle('bigwin_glow'); } private holdRespin() { this.spinning = true; this.setSpinEnabled(false); let newCoin = false; for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) { if (this.holdHeld[c] && this.holdHeld[c][r]) continue; const id = Math.random() < 0.18 ? this.randomBonusSymbol() : this.randRegular(); this.setSym(c, r, id); if (this.isBonus(id)) { this.holdHeld[c][r] = true; this.holdWinTotal += this.coinValue(c, r); newCoin = true; } } this.holdRespinsLeft = newCoin ? HOLD_RESPINS : this.holdRespinsLeft - 1; this.flashMult(`HOLD & WIN ${this.holdRespinsLeft}`); this.scheduleOnce(() => { if (this.holdRespinsLeft <= 0 || this.allHoldFilled()) { this.balance += this.holdWinTotal; this.roundWin += this.holdWinTotal; this.winLabel.string = `${this.roundWin}`; this.holdActive = false; this.holdHeld = []; this.floatWin(this.holdWinTotal); this.playParticle('coin_rain'); this.endRound(); } else { this.spinning = false; this.setSpinEnabled(true); } }, this.td(0.6)); } private allHoldFilled() { for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) if (!this.holdHeld[c][r]) return false; return true; } private coinValue(c: number, r: number) { return Math.floor(this.bet * (1 + ((c + r) % 5)) * PAYOUT_SCALE); } private randomBonusSymbol() { const bonus = SYMBOLS.filter((id: string) => this.isBonus(id)); return bonus.length ? bonus[Math.floor(Math.random() * bonus.length)] : this.rand(); } private randRegular() { const regular = SYMBOLS.filter((id: string) => !this.isWild(id) && !this.isScatter(id) && !this.isBonus(id)); return regular.length ? regular[Math.floor(Math.random() * regular.length)] : this.rand(); } private endRound() { if (this.roundWin > 0) { this.multLabel.string = ''; this.celebrate(this.roundWin); } else this.multLabel.string = ''; this.spinning = false; this.setSpinEnabled(true); if (this.holdActive) return; if (this.freeSpins > 0) this.scheduleOnce(() => this.spin(), this.td(0.9)); else if (this.auto && this.balance >= this.bet) this.scheduleOnce(() => this.spin(), this.td(0.9)); } private flashMult(text: string) { this.multLabel.string = text; const n = this.multLabel.node; n.setScale(0.6, 0.6, 1); tween(n).to(0.3, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' }).start(); } private playParticle(id: string) { resources.load(`vfx/${id}`, JsonAsset, (err, asset) => { if (err) return; const n = new Node('p'); n.parent = this.node; n.setPosition(0, this.frameCY + this.frameH / 2, 0); n.setSiblingIndex(26); const ps = n.addComponent(ParticleSystem2D); applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame); this.scheduleOnce(() => ps.stopSystem(), 1.2); this.scheduleOnce(() => n.destroy(), 4); }); } update(dt: number) { this.t += dt; if (Math.abs(this.displayBalance - this.balance) > 0.5) { this.displayBalance += (this.balance - this.displayBalance) * Math.min(1, dt * 6); this.balanceLabel.string = `${Math.floor(this.displayBalance)}`; } // 待机时每个果冻轻微上下呼吸,画面更"活" if (!this.spinning && this.cells.length) { for (let c = 0; c < COLS; c++) { for (let r = 0; r < ROWS; r++) { const sk = this.cells[c] && this.cells[c][r]; if (!sk) continue; const by = this.symBaseY[c] && this.symBaseY[c][r]; if (by === undefined) continue; const px = sk.node.position.x; sk.node.setPosition(px, by + Math.sin(this.t * 2.2 + c * 0.8 + r * 0.6) * this.cell * FEEDBACK.idleBob, 0); } } } } private rand() { return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]; } private reel(c: number) { const strip = REEL_STRIPS[c % Math.max(1, REEL_STRIPS.length)]; return strip && strip.length ? strip : SYMBOLS; } private reelRand(c: number) { const strip = this.reel(c); return strip[Math.floor(Math.random() * strip.length)]; } private setSpinEnabled(on: boolean) { const btn = this.spinBtn.getComponent(Button); if (btn) btn.interactable = on; } 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(); } }