SlotGame.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. // =============================================================
  2. // SlotGame.ts —— 《果冻消消乐 · Jelly Pop》消除式老虎机(可玩原型)
  3. // by anim_studio
  4. // v4:先预加载全部 UI 美术,再搭界面——HUD 胶囊用 hud_pill、
  5. // 圆按钮用 btn_round、SPIN 用 btn_spin;隐藏预览性能面板;
  6. // 4×5 大符号 + 每格圆角卡片。
  7. // =============================================================
  8. import {
  9. _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame, Sprite,
  10. ParticleSystem2D, UITransform, Label, Graphics, Button, Color, Vec3,
  11. view, EventTouch, tween, UIOpacity,
  12. } from 'cc';
  13. import * as cc from 'cc';
  14. import { applyParticleConfig } from './ParticleConfig';
  15. const { ccclass } = _decorator;
  16. const SYMBOLS = __SYMBOLS__;
  17. const GENERATED_CONFIG = __GAME_CONFIG__;
  18. // ============ 画面结构(先定区域,再往里填元素)============
  19. // 所有数值都是相对屏幕的比例;cy = 区域中心相对屏幕中心的 Y(向上为正)。
  20. const LAYOUT = (GENERATED_CONFIG.layout || {
  21. logo: { cy: 0.41, maxW: 0.82, maxH: 0.15 }, // 顶部 Logo
  22. reel: { cy: 0.02, h: 0.60, aspect: 0.652, // 卷轴框(按新图:霓虹边+白板)
  23. holeW: 0.86, holeH: 0.92, cols: 4, rows: 6 }, // 白板内孔 + 网格行列
  24. hud: { cy: -0.33, pillH: 0.40 }, // 三个 HUD 胶囊(按图比例)
  25. controls: { cy: -0.405, spinR: 0.13, smallR: 0.075, // 控制按钮(按宽度定标、做大)
  26. xMinus: 0.24, xTurbo: 0.40 },
  27. });
  28. const ECONOMY = GENERATED_CONFIG.economy || { startingBalance: 5000, defaultBet: 50 };
  29. const WIN_RULES = GENERATED_CONFIG.winRules || { minMatch: 4 };
  30. const FEATURE_RULES = GENERATED_CONFIG.features || {};
  31. const FEEDBACK_CONFIG = GENERATED_CONFIG.feedback || {};
  32. const PAYTABLE = GENERATED_CONFIG.paytable || {};
  33. const SYMBOL_RULES = GENERATED_CONFIG.symbols || [];
  34. const MATH_MODEL = GENERATED_CONFIG.mathModel || {};
  35. const PAYOUT_SCALE = MATH_MODEL.payoutScale ?? 1;
  36. const REEL_STRIPS = MATH_MODEL.reelStrips || [];
  37. const BOSS_CONFIG = GENERATED_CONFIG.boss || { enabled: false };
  38. const BOSS_ID = BOSS_CONFIG.id || 'boss_demon_lord';
  39. // ============ 文字样式表(统一定义,按角色取用)============
  40. // size<=1 表示相对所在容器高度的比例;否则按像素。ow=描边宽。
  41. const STYLE = {
  42. hudTitle: { size: 0.30, color: [205, 222, 255], bold: false, outline: [40, 28, 78], ow: 0, letter: 0.04 },
  43. hudValue: { size: 0.52, color: [255, 255, 255], bold: true, outline: [54, 30, 96], ow: 3 },
  44. mult: { size: 66, color: [255, 214, 70], bold: true, outline: [188, 70, 24], ow: 6 },
  45. floatWin: { size: 46, color: [255, 244, 130], bold: true, outline: [196, 92, 24], ow: 5 },
  46. bigWin: { size: 0.16, color: [255, 224, 70], bold: true, outline: [180, 50, 16], ow: 9 }, // 报幕(相对屏宽)
  47. };
  48. // ============ 每个符号的"按内容定标"参数(s=缩放/格, oyf=居中下移/格)============
  49. // 由素材实际内容包围盒算出,保证果冻填满格子、垂直居中——这就是"按规格画/摆"。
  50. const SYMFIT: Record<string, {s: number; oyf: number}> = __SYMBOL_FIT__;
  51. const SYMFIT_DEFAULT = __SYMBOL_FIT_DEFAULT__;
  52. // ============ 交互/动效反馈(统一定义)============
  53. const FEEDBACK = {
  54. btnPress: 0.86,
  55. btnBack: 0.16,
  56. idleBob: 0.016,
  57. dropFrom: 1.05,
  58. dropTime: FEEDBACK_CONFIG.dropTimeSec ?? 0.30,
  59. clearPop: FEEDBACK_CONFIG.clearPopScale ?? 1.30,
  60. spinDuration: FEEDBACK_CONFIG.spinDurationSec ?? 0.45,
  61. bigWinThresholdBet: FEEDBACK_CONFIG.bigWinThresholdBet ?? 25,
  62. screenShake: FEEDBACK_CONFIG.screenShake ?? false,
  63. };
  64. const COLS = LAYOUT.reel.cols, ROWS = LAYOUT.reel.rows;
  65. const MIN_MATCH = WIN_RULES.minMatch ?? 4;
  66. const CASCADE_RULES = FEATURE_RULES.cascades || {};
  67. const MAX_CASCADE = CASCADE_RULES.maxCascades ?? 6;
  68. const CASCADE_MULT_STEP = CASCADE_RULES.multiplierStep ?? 1;
  69. const CASCADE_MULT_MAX = CASCADE_RULES.maxMultiplier ?? 6;
  70. const START_BALANCE = ECONOMY.startingBalance ?? 5000;
  71. const DEFAULT_BET = ECONOMY.defaultBet ?? 50;
  72. const SCATTER_RULES = FEATURE_RULES.scatterFreeSpins || {};
  73. const HOLD_RULES = FEATURE_RULES.holdAndWin || {};
  74. const IS_CLUSTER = WIN_RULES.evaluation === 'cluster_count';
  75. const FREE_SPIN_TRIGGER = SCATTER_RULES.triggerCount ?? 3;
  76. const FREE_SPIN_AWARD = SCATTER_RULES.awardSpins ?? 8;
  77. const HOLD_TRIGGER = HOLD_RULES.triggerCount ?? 6;
  78. const HOLD_RESPINS = HOLD_RULES.respins ?? 3;
  79. const SYMBOL_ROLE: Record<string, string> = {};
  80. SYMBOL_RULES.forEach((s: any) => { SYMBOL_ROLE[s.id] = s.role; });
  81. @ccclass('SlotGame')
  82. export class SlotGame extends Component {
  83. private dataMap: Record<string, sp.SkeletonData> = {};
  84. private art: Record<string, SpriteFrame | null> = {};
  85. private cells: sp.Skeleton[][] = [];
  86. private ids: string[][] = [];
  87. private cell = 90;
  88. private gridX0 = 0;
  89. private gridY0 = 0;
  90. private spinning = false;
  91. private balance = START_BALANCE;
  92. private displayBalance = START_BALANCE;
  93. private bet = DEFAULT_BET;
  94. private multiplier = 1;
  95. private roundWin = 0;
  96. private cascade = 0;
  97. private freeSpins = 0;
  98. private holdActive = false;
  99. private holdRespinsLeft = 0;
  100. private holdHeld: boolean[][] = [];
  101. private holdWinTotal = 0;
  102. private balanceLabel!: Label;
  103. private betLabel!: Label;
  104. private winLabel!: Label;
  105. private multLabel!: Label;
  106. private spinBtn!: Node;
  107. private particleTex: SpriteFrame | null = null;
  108. private frameCY = 0; private frameW = 0; private frameH = 0;
  109. private cellW = 90; private cellH = 90;
  110. private W = 0; private H = 0;
  111. private symBaseY: number[][] = [];
  112. private t = 0;
  113. private turbo = false; private auto = false;
  114. private turboBtn!: Node; private autoBtn!: Node;
  115. private bossNode: Node | null = null;
  116. private bossSk: sp.Skeleton | null = null;
  117. private bossBusy = false;
  118. onLoad() {
  119. try { (cc as any).profiler?.hideStats?.(); } catch (e) {}
  120. this.node.setPosition(0, 0, 0);
  121. const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
  122. ut.setAnchorPoint(0.5, 0.5);
  123. const s = view.getVisibleSize();
  124. this.W = s.width; this.H = s.height;
  125. ut.setContentSize(s.width, s.height);
  126. this.computeLayout(s.width, s.height);
  127. this.buildBackground(s.width, s.height); // 渐变兜底
  128. resources.load('vfx/particle/spriteFrame', SpriteFrame, (_e, sf) => { if (sf) this.particleTex = sf; });
  129. const artIds = ['bg_main', 'reel_frame', 'logo', 'hud_pill', 'btn_round', 'btn_spin', 'btn_plus', 'btn_minus', 'btn_turbo', 'btn_auto', 'coin'];
  130. let toLoad = artIds.length;
  131. artIds.forEach((id) => {
  132. resources.load(`ui_art/${id}/spriteFrame`, SpriteFrame, (err, sf) => {
  133. this.art[id] = err ? null : sf;
  134. if (--toLoad === 0) this.afterArt();
  135. });
  136. });
  137. }
  138. private afterArt() {
  139. const W = this.W, H = this.H;
  140. this.buildArt(W, H);
  141. this.buildHud(W, H);
  142. this.buildControls(W, H);
  143. const loadIds = BOSS_CONFIG.enabled ? SYMBOLS.concat([BOSS_ID]) : SYMBOLS;
  144. let left = loadIds.length;
  145. loadIds.forEach((id) => {
  146. resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => {
  147. if (!err) this.dataMap[id] = data;
  148. if (--left === 0) { this.buildGrid(); this.buildBoss(); }
  149. });
  150. });
  151. }
  152. private computeLayout(W: number, H: number) {
  153. const R = LAYOUT.reel;
  154. let fh = H * R.h;
  155. let fw = fh * R.aspect;
  156. if (fw > W * 0.96) { fw = W * 0.96; fh = fw / R.aspect; }
  157. this.frameW = fw; this.frameH = fh;
  158. this.frameCY = H * R.cy;
  159. const innerW = fw * R.holeW, innerH = fh * R.holeH;
  160. this.cellW = innerW / COLS;
  161. this.cellH = innerH / ROWS;
  162. this.cell = Math.min(this.cellW, this.cellH);
  163. this.gridX0 = -innerW / 2 + this.cellW / 2;
  164. this.gridY0 = this.frameCY + innerH / 2 - this.cellH / 2;
  165. }
  166. private cellPos(c: number, r: number): [number, number] {
  167. return [this.gridX0 + c * this.cellW, this.gridY0 - r * this.cellH];
  168. }
  169. private buildBackground(W: number, H: number) {
  170. const n = new Node('bg'); n.parent = this.node; n.setSiblingIndex(0);
  171. const g = n.addComponent(Graphics);
  172. g.fillColor = new Color(120, 200, 255, 255); g.rect(-W / 2, 0, W, H / 2); g.fill();
  173. g.fillColor = new Color(255, 150, 175, 255); g.rect(-W / 2, -H / 2, W, H / 2); g.fill();
  174. }
  175. // ---- 背景大图 / 卷轴框 / Logo(有图用图,没图兜底)----
  176. private buildArt(W: number, H: number) {
  177. if (this.art['bg_main']) {
  178. const [w, h] = this.coverSize(this.art['bg_main']!, W, H);
  179. this.spriteNode(this.art['bg_main']!, w, h, 0, 0, 1);
  180. }
  181. if (this.art['reel_frame']) {
  182. this.spriteNode(this.art['reel_frame']!, this.frameW, this.frameH, 0, this.frameCY, 3);
  183. } else {
  184. const n = new Node('frame'); n.parent = this.node; n.setPosition(0, this.frameCY, 0); n.setSiblingIndex(3);
  185. const g = n.addComponent(Graphics);
  186. g.lineWidth = 10; g.strokeColor = new Color(120, 180, 255, 255);
  187. this.roundRect(g, -this.frameW / 2, -this.frameH / 2, this.frameW, this.frameH, 26); g.stroke();
  188. }
  189. const logoCy = H * LAYOUT.logo.cy;
  190. if (this.art['logo']) {
  191. const [w, h] = this.fitSize(this.art['logo']!, W * LAYOUT.logo.maxW, H * LAYOUT.logo.maxH);
  192. this.spriteNode(this.art['logo']!, w, h, 0, logoCy, 21);
  193. } else {
  194. const t = new Node('logo'); t.parent = this.node; t.setPosition(0, logoCy, 0); t.setSiblingIndex(21);
  195. const lab = t.addComponent(Label); lab.string = '🍬 JELLY POP'; lab.fontSize = 42; lab.color = new Color(255, 90, 70, 255);
  196. }
  197. }
  198. private buildBoss() {
  199. if (!BOSS_CONFIG.enabled || !this.dataMap[BOSS_ID]) return;
  200. const node = new Node('boss'); node.parent = this.node; node.setSiblingIndex(8);
  201. const sk = node.addComponent(sp.Skeleton);
  202. sk.skeletonData = this.dataMap[BOSS_ID];
  203. sk.premultipliedAlpha = false;
  204. sk.setAnimation(0, 'idle', true);
  205. const scale = Math.min(0.22, this.W / 3900);
  206. node.setScale(scale, scale, 1);
  207. node.setPosition(this.frameW * 0.40, this.frameCY + this.frameH * 0.32, 0);
  208. this.bossNode = node; this.bossSk = sk;
  209. this.schedule(() => this.bossIdleBeat(), 3.2);
  210. }
  211. // ---------------- HUD:用 hud_pill 美术 ----------------
  212. private buildHud(W: number, H: number) {
  213. const y = H * LAYOUT.hud.cy;
  214. const pw = (W - 36) / 3 - 8, gap = 8;
  215. const ps = this.art['hud_pill'];
  216. const ph = ps ? pw * (ps.rect.height / ps.rect.width) : pw * LAYOUT.hud.pillH; // 按图比例,不拉伸
  217. this.balanceLabel = this.makePill(-(pw + gap), y, pw, ph, '余额', `${START_BALANCE}`);
  218. this.betLabel = this.makePill(0, y, pw, ph, '下注', `${this.bet}`);
  219. this.winLabel = this.makePill(pw + gap, y, pw, ph, '本局赢', '0');
  220. const m = new Node('mult'); m.parent = this.node; m.setPosition(0, this.frameCY, 0); m.setSiblingIndex(25);
  221. this.multLabel = m.addComponent(Label); this.applyStyle(this.multLabel, STYLE.mult, 0); this.multLabel.string = '';
  222. }
  223. private makePill(x: number, y: number, w: number, h: number, title: string, val: string): Label {
  224. const n = new Node('pill'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(16);
  225. if (this.art['hud_pill']) {
  226. const s = n.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = this.art['hud_pill']!;
  227. } else {
  228. const g = n.addComponent(Graphics); g.fillColor = new Color(70, 50, 120, 235);
  229. this.roundRect(g, -w / 2, -h / 2, w, h, h / 2); g.fill();
  230. }
  231. const ut = n.getComponent(UITransform) || n.addComponent(UITransform); ut.setContentSize(w, h);
  232. // 标题(小字,靠左)
  233. const tl = new Node('t'); tl.parent = n; tl.setPosition(-w * 0.30, 0, 0);
  234. const tlab = tl.addComponent(Label); tlab.string = title; this.applyStyle(tlab, STYLE.hudTitle, h);
  235. // 数值(大字带描边,靠右)
  236. const vl = new Node('v'); vl.parent = n; vl.setPosition(w * 0.12, 0, 0);
  237. const lab = vl.addComponent(Label); lab.string = val; this.applyStyle(lab, STYLE.hudValue, h);
  238. return lab;
  239. }
  240. // 统一文字样式
  241. private applyStyle(lab: Label, st: any, base: number) {
  242. lab.fontSize = st.size <= 1 ? Math.round(base * st.size) : st.size;
  243. lab.lineHeight = lab.fontSize;
  244. lab.color = new Color(st.color[0], st.color[1], st.color[2], 255);
  245. if (st.bold) lab.isBold = true;
  246. if (st.ow && st.ow > 0) {
  247. lab.enableOutline = true;
  248. lab.outlineColor = new Color(st.outline[0], st.outline[1], st.outline[2], 255);
  249. lab.outlineWidth = st.ow;
  250. }
  251. }
  252. // 数字变化时弹一下
  253. private pop(node: Node, s = 1.22) {
  254. node.setScale(s, s, 1);
  255. tween(node).to(0.22, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }).start();
  256. }
  257. // 中奖时在网格中央飘一个 +N 上升淡出
  258. private floatWin(amount: number) {
  259. const n = new Node('fw'); n.parent = this.node; n.setPosition(0, this.frameCY + this.cell, 0); n.setSiblingIndex(27);
  260. const lab = n.addComponent(Label); lab.string = '+' + amount; this.applyStyle(lab, STYLE.floatWin, 0);
  261. const op = n.addComponent(UIOpacity);
  262. n.setScale(0.5, 0.5, 1);
  263. tween(n).to(0.16, { scale: new Vec3(1.15, 1.15, 1) }, { easing: 'backOut' })
  264. .to(0.12, { scale: new Vec3(1, 1, 1) }).start();
  265. tween(n).by(0.85, { position: new Vec3(0, this.cell * 1.6, 0) }, { easing: 'cubicOut' }).start();
  266. tween(op).delay(0.35).to(0.5, { opacity: 0 }).call(() => n.destroy()).start();
  267. }
  268. // ---------------- 控制区:btn_round / btn_spin 美术 ----------------
  269. private buildControls(W: number, H: number) {
  270. const C = LAYOUT.controls;
  271. const y = H * C.cy;
  272. const big = W * C.spinR; // SPIN 按屏宽定标,最突出
  273. const sr = W * C.smallR;
  274. this.spinBtn = this.makeBtn(0, y, big, 'btn_spin', '⟳', big * 0.6, () => this.spin());
  275. this.turboBtn = this.makeBtn(-W * C.xTurbo, y, sr, 'btn_turbo', '⚡', sr * 0.8, () => this.toggleTurbo());
  276. this.makeBtn(-W * C.xMinus, y, sr, 'btn_minus', '−', sr, () => this.changeBet(-10));
  277. this.makeBtn(W * C.xMinus, y, sr, 'btn_plus', '+', sr, () => this.changeBet(10));
  278. this.autoBtn = this.makeBtn(W * C.xTurbo, y, sr, 'btn_auto', '▶', sr * 0.8, () => this.toggleAuto());
  279. }
  280. private makeBtn(x: number, y: number, r: number, artId: string, glyph: string, fs: number, onClick: () => void): Node {
  281. const node = new Node('btn'); node.parent = this.node; node.setPosition(x, y, 0); node.setSiblingIndex(17);
  282. let hasArt = false;
  283. if (this.art[artId]) {
  284. const s = node.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = this.art[artId]!; hasArt = true;
  285. } else {
  286. const g = node.addComponent(Graphics); g.fillColor = new Color(90, 70, 140, 255); g.circle(0, 0, r); g.fill();
  287. g.lineWidth = 4; g.strokeColor = new Color(255, 255, 255, 235); g.circle(0, 0, r); g.stroke();
  288. }
  289. const ut = node.getComponent(UITransform) || node.addComponent(UITransform); ut.setContentSize(r * 2, r * 2);
  290. // 有美术(图标已烤进图里)就不叠字;没有才用文字兜底
  291. if (!hasArt) {
  292. const gl = new Node('g'); gl.parent = node;
  293. const lab = gl.addComponent(Label); lab.string = glyph; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 255);
  294. }
  295. const btn = node.addComponent(Button);
  296. btn.transition = Button.Transition.NONE; // 自己控制反馈,更跟手
  297. const press = () => { tween(node).to(0.07, { scale: new Vec3(FEEDBACK.btnPress, FEEDBACK.btnPress, 1) }).start(); };
  298. const release = () => { tween(node).to(FEEDBACK.btnBack, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }).start(); };
  299. node.on(Node.EventType.TOUCH_START, press);
  300. node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => { release(); onClick(); });
  301. node.on(Node.EventType.TOUCH_CANCEL, release);
  302. return node;
  303. }
  304. private makeTextLabel(x: number, y: number, text: string, fs: number) {
  305. const n = new Node('tl'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(17);
  306. const lab = n.addComponent(Label); lab.string = text; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 235);
  307. }
  308. private changeBet(d: number) {
  309. if (this.spinning) return;
  310. this.bet = Math.max(10, Math.min(500, this.bet + d));
  311. this.betLabel.string = `${this.bet}`;
  312. this.pop(this.betLabel.node);
  313. }
  314. // TURBO:加速;AUTO:自动连转。带高亮状态。
  315. private td(v: number) { return this.turbo ? v * 0.4 : v; }
  316. private toggleTurbo() {
  317. this.turbo = !this.turbo; this.tintBtn(this.turboBtn, this.turbo);
  318. }
  319. private toggleAuto() {
  320. this.auto = !this.auto; this.tintBtn(this.autoBtn, this.auto);
  321. if (this.auto && !this.spinning) this.spin();
  322. }
  323. private tintBtn(node: Node, on: boolean) {
  324. const s = node.getComponent(Sprite);
  325. if (s) s.color = on ? new Color(255, 224, 90, 255) : new Color(255, 255, 255, 255);
  326. tween(node).to(0.12, { scale: new Vec3(on ? 1.12 : 1, on ? 1.12 : 1, 1) }, { easing: 'backOut' }).start();
  327. }
  328. // 大额报幕 + 金币雨
  329. private celebrate(amount: number) {
  330. const n = new Node('bigwin'); n.parent = this.node; n.setPosition(0, this.frameCY, 0);
  331. n.setSiblingIndex(this.node.children.length); // 强制置顶
  332. const lab = n.addComponent(Label); lab.string = '+' + amount; this.applyStyle(lab, STYLE.bigWin, this.W);
  333. const op = n.addComponent(UIOpacity);
  334. n.setScale(0.2, 0.2, 1);
  335. tween(n).to(0.28, { scale: new Vec3(1.18, 1.18, 1) }, { easing: 'backOut' })
  336. .to(0.12, { scale: new Vec3(1, 1, 1) })
  337. .delay(0.6).to(0.25, { scale: new Vec3(1.25, 1.25, 1) }).start();
  338. tween(op).delay(0.95).to(0.35, { opacity: 0 }).call(() => n.destroy()).start();
  339. this.coinShower(Math.max(12, Math.min(40, Math.floor(amount / 15))));
  340. }
  341. private playBossAnim(name: string, loop = false) {
  342. if (!this.bossNode || !this.bossSk) return;
  343. try { this.bossSk.setAnimation(0, name, loop); }
  344. catch (e) { try { this.bossSk.setAnimation(0, 'idle', true); } catch (_e) {} }
  345. }
  346. private addBossAnim(name: string, loop = false, delay = 0) {
  347. if (!this.bossSk) return;
  348. try { this.bossSk.addAnimation(0, name, loop, delay); } catch (e) {}
  349. }
  350. private bossIdleBeat() {
  351. if (!this.bossNode || !this.bossSk || this.spinning || this.bossBusy) return;
  352. const anim = Math.random() > 0.5 ? 'watch' : 'charge';
  353. this.playBossAnim(anim, false);
  354. this.addBossAnim('idle', true, 0);
  355. const n = this.bossNode;
  356. const base = n.scale.x;
  357. tween(n).to(0.25, { scale: new Vec3(base * 1.04, base * 0.98, 1) })
  358. .to(0.28, { scale: new Vec3(base, base, 1) }).start();
  359. }
  360. private bossDefeated(amount: number) {
  361. if (!this.bossNode || !this.bossSk) return;
  362. this.bossBusy = true;
  363. this.playBossAnim('coin_throw', false);
  364. this.addBossAnim('hurt', false, 0.05);
  365. this.addBossAnim('explode', false, 0.08);
  366. this.addBossAnim('idle', true, 0.35);
  367. this.playParticle('boss_explosion');
  368. this.coinShower(Math.max(10, Math.min(34, Math.floor(amount / 20))));
  369. const n = this.bossNode;
  370. const base = n.scale.x;
  371. tween(n).to(0.10, { scale: new Vec3(base * 1.18, base * 0.86, 1), angle: -7 })
  372. .to(0.12, { scale: new Vec3(base * 0.82, base * 1.18, 1), angle: 8 })
  373. .to(0.18, { scale: new Vec3(base, base, 1), angle: 0 }, { easing: 'backOut' })
  374. .call(() => { this.bossBusy = false; }).start();
  375. this.flashMult('大魔王撒币后裂开爆炸!');
  376. }
  377. private bossTaunt() {
  378. if (!this.bossNode || !this.bossSk) return;
  379. this.bossBusy = true;
  380. this.playBossAnim('taunt', false);
  381. this.addBossAnim('stomp', false, 0.04);
  382. this.addBossAnim('attack', false, 0.02);
  383. this.addBossAnim('idle', true, 0);
  384. const n = this.bossNode;
  385. const x = n.position.x, y = n.position.y;
  386. tween(n).to(0.12, { position: new Vec3(x - this.cell * 0.12, y + this.cell * 0.10, 0), angle: -5 })
  387. .to(0.16, { position: new Vec3(x + this.cell * 0.10, y - this.cell * 0.18, 0), angle: 7 })
  388. .to(0.18, { position: new Vec3(x, y, 0), angle: 0 }, { easing: 'backOut' })
  389. .call(() => { this.bossBusy = false; }).start();
  390. this.flashMult('大魔王举剑踩踏耀武扬威');
  391. }
  392. private coinShower(count: number) {
  393. const sf = this.art['coin']; if (!sf) return;
  394. const sz = this.cell * 0.6;
  395. for (let i = 0; i < count; i++) {
  396. const x0 = (Math.random() * 2 - 1) * this.frameW * 0.42;
  397. const c = this.spriteNode(sf, sz, sz, x0, this.H * 0.5 + this.cell, this.node.children.length + 1);
  398. const op = c.addComponent(UIOpacity);
  399. const dur = 0.9 + Math.random() * 0.7;
  400. const dx = (Math.random() * 2 - 1) * this.W * 0.12;
  401. tween(c).delay(Math.random() * 0.35)
  402. .by(dur, { position: new Vec3(dx, -this.H - this.cell * 2, 0), angle: (Math.random() * 2 - 1) * 720 }, { easing: 'sineIn' })
  403. .call(() => c.destroy()).start();
  404. tween(op).delay(dur * 0.7).to(dur * 0.3, { opacity: 0 }).start();
  405. }
  406. }
  407. private fitSize(sf: SpriteFrame, boxW: number, boxH: number): [number, number] {
  408. const r = sf.rect; const aw = r.width || 1, ah = r.height || 1;
  409. const s = Math.min(boxW / aw, boxH / ah); return [aw * s, ah * s];
  410. }
  411. private coverSize(sf: SpriteFrame, boxW: number, boxH: number): [number, number] {
  412. const r = sf.rect; const aw = r.width || 1, ah = r.height || 1;
  413. const s = Math.max(boxW / aw, boxH / ah); return [aw * s, ah * s];
  414. }
  415. private spriteNode(sf: SpriteFrame, w: number, h: number, x: number, y: number, sib: number): Node {
  416. const n = new Node('art'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(sib);
  417. const s = n.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = sf;
  418. const ut = n.getComponent(UITransform) || n.addComponent(UITransform); ut.setContentSize(w, h);
  419. return n;
  420. }
  421. // ---------------- 网格:圆角卡片 + 大符号 ----------------
  422. private fit(id: string) { return SYMFIT[id] || SYMFIT_DEFAULT; }
  423. private buildGrid() {
  424. const cardW = this.cellW * 0.90;
  425. const cardH = this.cellH * 0.90;
  426. const cardR = Math.min(cardW, cardH) * 0.18;
  427. for (let c = 0; c < COLS; c++) {
  428. this.cells[c] = []; this.ids[c] = []; this.symBaseY[c] = [];
  429. for (let r = 0; r < ROWS; r++) {
  430. const id = this.reelRand(c);
  431. const [x, y] = this.cellPos(c, r);
  432. const card = new Node(`card_${c}_${r}`); card.parent = this.node; card.setPosition(x, y, 0); card.setSiblingIndex(5);
  433. const cg = card.addComponent(Graphics);
  434. cg.fillColor = new Color(255, 255, 255, 215);
  435. this.roundRect(cg, -cardW / 2, -cardH / 2, cardW, cardH, cardR); cg.fill();
  436. cg.lineWidth = 3; cg.strokeColor = new Color(120, 175, 255, 240);
  437. this.roundRect(cg, -cardW / 2, -cardH / 2, cardW, cardH, cardR); cg.stroke();
  438. const f = this.fit(id);
  439. const scale = Math.min(this.cellW, this.cellH) * f.s;
  440. const baseY = y - Math.min(this.cellW, this.cellH) * f.oyf;
  441. this.symBaseY[c][r] = baseY;
  442. const node = new Node(`cell_${c}_${r}`); node.parent = this.node; node.setSiblingIndex(6);
  443. node.setPosition(x, baseY, 0); node.setScale(scale, scale, 1);
  444. const sk = node.addComponent(sp.Skeleton);
  445. sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false;
  446. sk.setAnimation(0, 'idle', true);
  447. this.cells[c][r] = sk; this.ids[c][r] = id;
  448. }
  449. }
  450. }
  451. private setSym(c: number, r: number, id: string) {
  452. const sk = this.cells[c][r];
  453. sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false;
  454. sk.setAnimation(0, 'idle', true);
  455. this.ids[c][r] = id;
  456. const [x, y] = this.cellPos(c, r);
  457. const f = this.fit(id);
  458. const scale = Math.min(this.cellW, this.cellH) * f.s;
  459. const baseY = y - Math.min(this.cellW, this.cellH) * f.oyf;
  460. this.symBaseY[c][r] = baseY;
  461. const nd = sk.node;
  462. nd.setScale(scale, scale, 1);
  463. nd.setPosition(x, baseY + this.cell * FEEDBACK.dropFrom, 0); // 从上方落入
  464. tween(nd).to(FEEDBACK.dropTime, { position: new Vec3(x, baseY, 0) }, { easing: 'backOut' }).start();
  465. }
  466. private spin() {
  467. if (this.spinning) return;
  468. if (this.holdActive) { this.holdRespin(); return; }
  469. const freeSpin = this.freeSpins > 0;
  470. if (!freeSpin && this.balance < this.bet) { this.flashMult('余额不足'); return; }
  471. if (freeSpin) {
  472. this.freeSpins -= 1;
  473. this.flashMult(`免费旋转 ${this.freeSpins}`);
  474. } else {
  475. this.balance -= this.bet; this.displayBalance = this.balance;
  476. this.balanceLabel.string = `${Math.floor(this.displayBalance)}`;
  477. this.pop(this.balanceLabel.node);
  478. }
  479. this.multiplier = 1; this.roundWin = 0; this.cascade = 0;
  480. this.winLabel.string = '0'; this.multLabel.string = '';
  481. this.spinning = true; this.setSpinEnabled(false);
  482. tween(this.spinBtn).by(FEEDBACK.spinDuration, { angle: -360 }, { easing: 'cubicOut' }).start(); // 联动旋转
  483. for (let c = 0; c < COLS; c++) {
  484. const strip = this.reel(c);
  485. const start = Math.floor(Math.random() * strip.length);
  486. for (let r = 0; r < ROWS; r++) this.setSym(c, r, strip[(start + r) % strip.length]);
  487. }
  488. this.scheduleOnce(() => this.resolve(), this.td(0.45));
  489. }
  490. private resolve() {
  491. if (this.cascade === 0) this.resolveFeatureTriggers();
  492. const count: Record<string, number> = {};
  493. 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;
  494. const result = IS_CLUSTER ? this.findClusterWins() : this.findCountWins(count);
  495. if (result.groups.length === 0) { this.endRound(); return; }
  496. let cleared = 0; const winners: sp.Skeleton[] = [];
  497. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
  498. if (result.mask[c] && result.mask[c][r]) { winners.push(this.cells[c][r]); cleared++; }
  499. }
  500. const pay = this.calcPay(result.groups);
  501. this.roundWin += pay; this.balance += pay; this.winLabel.string = `${this.roundWin}`;
  502. this.pop(this.winLabel.node, 1.3); this.floatWin(pay);
  503. this.flashMult(`x${this.multiplier} +${pay}`);
  504. winners.forEach((sk) => {
  505. sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0);
  506. const nd = sk.node; const sc = nd.scale.x;
  507. tween(nd).to(0.1, { scale: new Vec3(sc * FEEDBACK.clearPop, sc * FEEDBACK.clearPop, 1) })
  508. .to(0.14, { scale: new Vec3(sc, sc, 1) }, { easing: 'backOut' }).start();
  509. });
  510. this.playParticle('coin_rain');
  511. this.cascade += 1;
  512. this.scheduleOnce(() => {
  513. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++)
  514. if (result.mask[c] && result.mask[c][r]) this.setSym(c, r, this.reelRand(c));
  515. this.multiplier = Math.min(CASCADE_MULT_MAX, this.multiplier + CASCADE_MULT_STEP);
  516. if (this.cascade >= MAX_CASCADE) { this.endRound(); return; }
  517. this.scheduleOnce(() => this.resolve(), this.td(0.4));
  518. }, this.td(0.6));
  519. }
  520. private calcPay(groups: {id: string; n: number}[]) {
  521. let total = 0;
  522. groups.forEach(({id, n}) => {
  523. const table = PAYTABLE[id] || {};
  524. const capped = Math.min(6, Math.max(MIN_MATCH, n));
  525. const mult = Number(table[String(capped)] ?? table[String(MIN_MATCH)] ?? capped);
  526. total += mult * this.bet;
  527. });
  528. return Math.max(1, Math.floor(total * this.multiplier * PAYOUT_SCALE));
  529. }
  530. private resolveFeatureTriggers() {
  531. if (SCATTER_RULES.enabled) {
  532. const scatterCount = this.countRole('scatter');
  533. if (scatterCount >= FREE_SPIN_TRIGGER) {
  534. const scatterPay = this.featurePay('scatter', scatterCount);
  535. this.freeSpins += FREE_SPIN_AWARD;
  536. if (scatterPay > 0) { this.roundWin += scatterPay; this.balance += scatterPay; this.winLabel.string = `${this.roundWin}`; }
  537. this.flashMult(`FREE SPINS +${FREE_SPIN_AWARD}`);
  538. this.playParticle('bigwin_glow');
  539. }
  540. }
  541. if (HOLD_RULES.enabled && !this.holdActive && this.countRole('bonus') >= HOLD_TRIGGER) {
  542. this.startHoldAndWin();
  543. }
  544. }
  545. private findCountWins(count: Record<string, number>) {
  546. const wildCount = this.countRole('wild');
  547. const mask = this.emptyMask();
  548. const groups = Object.keys(count).filter((id) => {
  549. if (this.isWild(id) || this.isScatter(id) || this.isBonus(id)) return false;
  550. return (count[id] || 0) + wildCount >= MIN_MATCH;
  551. }).map((id) => ({id, n: (count[id] || 0) + wildCount}));
  552. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
  553. const id = this.ids[c][r];
  554. if (groups.some((g) => g.id === id) || (this.isWild(id) && groups.length > 0)) mask[c][r] = true;
  555. }
  556. return {groups, mask};
  557. }
  558. private findClusterWins() {
  559. const groups: {id: string; n: number}[] = [];
  560. const mask = this.emptyMask();
  561. const regular = SYMBOLS.filter((id: string) => !this.isWild(id) && !this.isScatter(id) && !this.isBonus(id));
  562. regular.forEach((sid: string) => {
  563. const seen = this.emptyMask();
  564. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
  565. if (seen[c][r] || (this.ids[c][r] !== sid && !this.isWild(this.ids[c][r]))) continue;
  566. const stack: [number, number][] = [[c, r]];
  567. const cells: [number, number][] = [];
  568. seen[c][r] = true;
  569. while (stack.length) {
  570. const [x, y] = stack.pop()!;
  571. cells.push([x, y]);
  572. [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]].forEach(([nx, ny]) => {
  573. if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS || seen[nx][ny]) return;
  574. const nid = this.ids[nx][ny];
  575. if (nid === sid || this.isWild(nid)) { seen[nx][ny] = true; stack.push([nx, ny]); }
  576. });
  577. }
  578. if (cells.length >= MIN_MATCH) {
  579. groups.push({id: sid, n: cells.length});
  580. cells.forEach(([x, y]) => { mask[x][y] = true; });
  581. }
  582. }
  583. });
  584. return {groups, mask};
  585. }
  586. private emptyMask() {
  587. const mask: boolean[][] = [];
  588. for (let c = 0; c < COLS; c++) {
  589. mask[c] = [];
  590. for (let r = 0; r < ROWS; r++) mask[c][r] = false;
  591. }
  592. return mask;
  593. }
  594. private featurePay(id: string, count: number) {
  595. const table = PAYTABLE[id] || {};
  596. const n = Math.min(6, Math.max(3, count));
  597. return Math.floor(Number(table[String(n)] ?? 0) * this.bet * PAYOUT_SCALE);
  598. }
  599. private role(id: string) { return SYMBOL_ROLE[id] || ''; }
  600. private isWild(id: string) { return this.role(id) === 'wild' || id === 'wild'; }
  601. private isScatter(id: string) { return this.role(id) === 'scatter' || id.indexOf('scatter') >= 0; }
  602. private isBonus(id: string) { return this.role(id) === 'bonus' || id.indexOf('coin') >= 0 || id === 'collect'; }
  603. private countRole(role: string) {
  604. let n = 0;
  605. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
  606. const id = this.ids[c][r];
  607. if ((role === 'wild' && this.isWild(id)) || (role === 'scatter' && this.isScatter(id)) || (role === 'bonus' && this.isBonus(id))) n++;
  608. }
  609. return n;
  610. }
  611. private startHoldAndWin() {
  612. this.holdActive = true;
  613. this.holdRespinsLeft = HOLD_RESPINS;
  614. this.holdWinTotal = 0;
  615. this.holdHeld = [];
  616. for (let c = 0; c < COLS; c++) {
  617. this.holdHeld[c] = [];
  618. for (let r = 0; r < ROWS; r++) {
  619. const held = this.isBonus(this.ids[c][r]);
  620. this.holdHeld[c][r] = held;
  621. if (held) this.holdWinTotal += this.coinValue(c, r);
  622. }
  623. }
  624. this.flashMult(`HOLD & WIN ${this.holdRespinsLeft}`);
  625. this.playParticle('bigwin_glow');
  626. }
  627. private holdRespin() {
  628. this.spinning = true; this.setSpinEnabled(false);
  629. let newCoin = false;
  630. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
  631. if (this.holdHeld[c] && this.holdHeld[c][r]) continue;
  632. const id = Math.random() < 0.18 ? this.randomBonusSymbol() : this.randRegular();
  633. this.setSym(c, r, id);
  634. if (this.isBonus(id)) {
  635. this.holdHeld[c][r] = true;
  636. this.holdWinTotal += this.coinValue(c, r);
  637. newCoin = true;
  638. }
  639. }
  640. this.holdRespinsLeft = newCoin ? HOLD_RESPINS : this.holdRespinsLeft - 1;
  641. this.flashMult(`HOLD & WIN ${this.holdRespinsLeft}`);
  642. this.scheduleOnce(() => {
  643. if (this.holdRespinsLeft <= 0 || this.allHoldFilled()) {
  644. this.balance += this.holdWinTotal; this.roundWin += this.holdWinTotal; this.winLabel.string = `${this.roundWin}`;
  645. this.holdActive = false; this.holdHeld = [];
  646. this.floatWin(this.holdWinTotal); this.playParticle('coin_rain');
  647. this.endRound();
  648. } else {
  649. this.spinning = false; this.setSpinEnabled(true);
  650. }
  651. }, this.td(0.6));
  652. }
  653. private allHoldFilled() {
  654. for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) if (!this.holdHeld[c][r]) return false;
  655. return true;
  656. }
  657. private coinValue(c: number, r: number) { return Math.floor(this.bet * (1 + ((c + r) % 5)) * PAYOUT_SCALE); }
  658. private randomBonusSymbol() {
  659. const bonus = SYMBOLS.filter((id: string) => this.isBonus(id));
  660. return bonus.length ? bonus[Math.floor(Math.random() * bonus.length)] : this.rand();
  661. }
  662. private randRegular() {
  663. const regular = SYMBOLS.filter((id: string) => !this.isWild(id) && !this.isScatter(id) && !this.isBonus(id));
  664. return regular.length ? regular[Math.floor(Math.random() * regular.length)] : this.rand();
  665. }
  666. private endRound() {
  667. if (this.roundWin > 0) { this.multLabel.string = ''; this.celebrate(this.roundWin); this.bossDefeated(this.roundWin); }
  668. else { this.multLabel.string = ''; this.bossTaunt(); }
  669. this.spinning = false; this.setSpinEnabled(true);
  670. if (this.holdActive) return;
  671. if (this.freeSpins > 0) this.scheduleOnce(() => this.spin(), this.td(0.9));
  672. else if (this.auto && this.balance >= this.bet) this.scheduleOnce(() => this.spin(), this.td(0.9));
  673. }
  674. private flashMult(text: string) {
  675. this.multLabel.string = text;
  676. const n = this.multLabel.node; n.setScale(0.6, 0.6, 1);
  677. tween(n).to(0.3, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' }).start();
  678. }
  679. private playParticle(id: string) {
  680. resources.load(`vfx/${id}`, JsonAsset, (err, asset) => {
  681. if (err) return;
  682. const n = new Node('p'); n.parent = this.node; n.setPosition(0, this.frameCY + this.frameH / 2, 0); n.setSiblingIndex(26);
  683. const ps = n.addComponent(ParticleSystem2D);
  684. applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame);
  685. this.scheduleOnce(() => ps.stopSystem(), 1.2);
  686. this.scheduleOnce(() => n.destroy(), 4);
  687. });
  688. }
  689. update(dt: number) {
  690. this.t += dt;
  691. if (Math.abs(this.displayBalance - this.balance) > 0.5) {
  692. this.displayBalance += (this.balance - this.displayBalance) * Math.min(1, dt * 6);
  693. this.balanceLabel.string = `${Math.floor(this.displayBalance)}`;
  694. }
  695. // 待机时每个果冻轻微上下呼吸,画面更"活"
  696. if (!this.spinning && this.cells.length) {
  697. for (let c = 0; c < COLS; c++) {
  698. for (let r = 0; r < ROWS; r++) {
  699. const sk = this.cells[c] && this.cells[c][r]; if (!sk) continue;
  700. const by = this.symBaseY[c] && this.symBaseY[c][r]; if (by === undefined) continue;
  701. const px = sk.node.position.x;
  702. sk.node.setPosition(px, by + Math.sin(this.t * 2.2 + c * 0.8 + r * 0.6) * this.cell * FEEDBACK.idleBob, 0);
  703. }
  704. }
  705. }
  706. }
  707. private rand() { return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]; }
  708. private reel(c: number) {
  709. const strip = REEL_STRIPS[c % Math.max(1, REEL_STRIPS.length)];
  710. return strip && strip.length ? strip : SYMBOLS;
  711. }
  712. private reelRand(c: number) {
  713. const strip = this.reel(c);
  714. return strip[Math.floor(Math.random() * strip.length)];
  715. }
  716. private setSpinEnabled(on: boolean) {
  717. const btn = this.spinBtn.getComponent(Button); if (btn) btn.interactable = on;
  718. }
  719. private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) {
  720. g.moveTo(x + r, y); g.lineTo(x + w - r, y);
  721. g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false);
  722. g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false);
  723. g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false);
  724. g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close();
  725. }
  726. }