// ReelView — masked spinning reel columns for SlotsGame. // // Each reel is a recycled stack of rows+2 Images windowed by a shared geometry // mask. Spin physics: accelerate → constant scroll (with a scaleY/alpha blur // fake) → engine-supplied stop with a surge-out ease and settle bounce. // Anticipation reels get a long, pulsing slow-stop. Liberty Belle uses stepped // notch stops. The view only ever displays what the engine decided. import { textureKeyFor } from './SlotsSymbols.js'; import { playSound, SFX } from '../../ui/Sounds.js'; const SPIN_SPEED = 0.024; // cells per ms at full speed const ACCEL_MS = 160; const STAGGER_MS = 220; const ANTICIPATION_MS = 1500; export class ReelView { constructor(scene, machine, { x, y, cellW, cellH, gap = 10, depth = 10 }) { this.scene = scene; this.machine = machine; this.x = x; this.y = y; this.cellW = cellW; this.cellH = cellH; this.gap = gap; this.depth = depth; this.rows = machine.layout.rows; this.nReels = machine.strips.length; this.spinningCount = 0; this.overlays = []; // value texts pinned to cells this.stickyImgs = []; // "stuck to the glass" wild overlays const w = this.width(); const h = this.height(); // Reel backplates + window frame. const g = scene.add.graphics().setDepth(depth - 2); g.fillStyle(0x000000, 0.85); g.fillRoundedRect(x - 10, y - 10, w + 20, h + 20, 18); for (let c = 0; c < this.nReels; c++) { g.fillStyle(machine.theme.reelBg, 1); g.fillRoundedRect(this.reelX(c), y, cellW, h, 10); } this.frame = g; // Shared window mask. const maskG = scene.make.graphics({ add: false }); maskG.fillStyle(0xffffff); maskG.fillRect(x, y, w, h); this.mask = maskG.createGeometryMask(); this.maskG = maskG; // Per-reel image stacks. this.reels = []; for (let c = 0; c < this.nReels; c++) { const imgs = []; for (let i = -1; i <= this.rows; i++) { const img = scene.add.image(this.reelX(c) + cellW / 2, 0, '__DEFAULT') .setDepth(depth).setMask(this.mask); imgs.push(img); } this.reels.push({ imgs, pos: 0, speed: 0, spinning: false, bounce: 0, blur: 0, lastNotch: 0, }); this.applyReel(c); } // Shade strips top/bottom for reel curvature. const shade = scene.add.graphics().setDepth(depth + 1).setMask(this.mask); shade.fillGradientStyle(0x000000, 0x000000, 0x000000, 0x000000, 0.45, 0.45, 0, 0); shade.fillRect(x, y, w, Math.min(34, h * 0.18)); shade.fillGradientStyle(0x000000, 0x000000, 0x000000, 0x000000, 0, 0, 0.45, 0.45); shade.fillRect(x, y + h - Math.min(34, h * 0.18), w, Math.min(34, h * 0.18)); this.shade = shade; this.updateHandler = (time, delta) => this.update(delta); scene.events.on('update', this.updateHandler); scene.events.once('shutdown', () => this.destroy(true)); } width() { return this.nReels * this.cellW + (this.nReels - 1) * this.gap; } height() { return this.rows * this.cellH; } reelX(c) { return this.x + c * (this.cellW + this.gap); } cellCenter(c, r) { return { x: this.reelX(c) + this.cellW / 2, y: this.y + r * this.cellH + this.cellH / 2 }; } image(c, r) { return this.reels[c].imgs[r + 1]; } symKey(symbolId) { return textureKeyFor(this.scene, this.machine.id, symbolId); } // Position + texture every image of one reel from its float strip pos. applyReel(c) { const reel = this.reels[c]; const strip = this.machine.strips[c]; const len = strip.length; const top = Math.floor(reel.pos); const frac = reel.pos - top; for (let i = -1; i <= this.rows; i++) { const img = reel.imgs[i + 1]; const stripIdx = ((top + i) % len + len) % len; const key = this.symKey(strip[stripIdx]); if (img.texture.key !== key) { img.setTexture(key); img.setDisplaySize(this.cellW * 0.88, this.cellH * 0.88); img.baseScaleX = img.scaleX; img.baseScaleY = img.scaleY; } img.y = this.y + (i + frac) * this.cellH + this.cellH / 2 + reel.bounce; const blur = reel.blur; img.scaleY = (img.baseScaleY ?? img.scaleY) * (1 + blur * 0.55); img.scaleX = img.baseScaleX ?? img.scaleX; img.alpha = 1 - blur * 0.3; } } update(delta) { for (let c = 0; c < this.nReels; c++) { const reel = this.reels[c]; if (!reel.spinning) continue; const len = this.machine.strips[c].length; reel.pos = ((reel.pos - reel.speed * delta) % len + len) % len; this.applyReel(c); } } // Show a resolved grid instantly (used after spins/tumbles to make the // canonical engine grid authoritative on screen). showGrid(grid) { for (let c = 0; c < this.nReels; c++) { const reel = this.reels[c]; reel.spinning = false; reel.speed = 0; reel.blur = 0; reel.bounce = 0; const strip = this.machine.strips[c]; const len = strip.length; for (let i = -1; i <= this.rows; i++) { const img = reel.imgs[i + 1]; const inWindow = i >= 0 && i < this.rows; const symbolId = inWindow ? grid[c][i] : strip[((Math.floor(reel.pos) + i) % len + len) % len]; img.setTexture(this.symKey(symbolId)); img.setDisplaySize(this.cellW * 0.88, this.cellH * 0.88); img.baseScaleX = img.scaleX; img.baseScaleY = img.scaleY; img.alpha = 1; img.y = this.y + i * this.cellH + this.cellH / 2; } } } // Spin all reels to the given stops. Resolves when the last reel settles. // opts: { anticipationFrom: reelIndex|null, stepped: bool, held: bool[] } spin(stops, opts = {}) { const { anticipationFrom = null, stepped = false, held = [] } = opts; this.clearOverlays(); return new Promise((resolve) => { let remaining = 0; let extra = 0; for (let c = 0; c < this.nReels; c++) { if (held[c]) continue; remaining++; const anticipate = anticipationFrom !== null && c >= anticipationFrom; if (anticipate) extra += ANTICIPATION_MS; const stopDelay = 520 + c * STAGGER_MS + (anticipate ? extra : 0); this.startReel(c, stops[c], stopDelay, stepped, anticipate, () => { playSound(this.scene, SFX.PIECE_CLICK); if (this.onReelStop) this.onReelStop(c, anticipate); if (--remaining === 0) resolve(); }); } if (remaining === 0) resolve(); }); } startReel(c, stop, stopDelay, stepped, anticipate, done) { const scene = this.scene; const reel = this.reels[c]; const len = this.machine.strips[c].length; reel.spinning = true; // Accelerate into the blur. scene.tweens.add({ targets: reel, speed: SPIN_SPEED, blur: 1, duration: ACCEL_MS, ease: 'Quad.in', }); scene.time.delayedCall(stopDelay, () => { // Hand over from free-running scroll to a deterministic surge-out stop. reel.spinning = false; reel.blur = 0; const start = reel.pos; let distance = ((start - stop) % len + len) % len; distance += len * (stepped ? 1 : 2); // at least full wraps for drama const carrier = { t: 0 }; let lastStepNotch = 0; scene.tweens.add({ targets: carrier, t: 1, duration: stepped ? Math.min(1500, 220 * Math.min(distance, 7)) : (anticipate ? 1150 : 680), ease: stepped ? 'Stepped' : 'Cubic.easeOut', easeParams: stepped ? [Math.min(distance, 10)] : undefined, onUpdate: () => { reel.pos = ((start - distance * carrier.t) % len + len) % len; if (stepped) { const notch = Math.floor(carrier.t * Math.min(distance, 10)); if (notch !== lastStepNotch) { lastStepNotch = notch; playSound(scene, SFX.PIECE_CLICK); } } this.applyReel(c); }, onComplete: () => { reel.pos = stop; this.applyReel(c); // Settle bounce. scene.tweens.add({ targets: reel, bounce: 12, duration: 90, yoyo: true, ease: 'Quad.out', onUpdate: () => this.applyReel(c), onComplete: () => { reel.bounce = 0; this.applyReel(c); done(); }, }); }, }); }); } // Bump one reel a single notch down (Fruit Frenzy nudge). nudge(c, stop) { return new Promise((resolve) => { const reel = this.reels[c]; const len = this.machine.strips[c].length; const start = reel.pos; const carrier = { t: 0 }; this.scene.tweens.add({ targets: carrier, t: 1, duration: 200, ease: 'Back.easeOut', onUpdate: () => { reel.pos = ((start - carrier.t) % len + len) % len; this.applyReel(c); }, onComplete: () => { reel.pos = ((stop % len) + len) % len; this.applyReel(c); playSound(this.scene, SFX.PIECE_CLICK); resolve(); }, }); }); } // ── Cell overlays (coin values, rainbow multipliers) ─────────────────────── setOverlayText(c, r, str, style = {}) { const { x, y } = this.cellCenter(c, r); const t = this.scene.add.text(x, y + (style.dy ?? 0), str, { fontFamily: 'Righteous', fontSize: `${style.size ?? 26}px`, color: style.color ?? '#fff6d8', stroke: '#14100a', strokeThickness: 5, }).setOrigin(0.5).setDepth(this.depth + 3).setMask(this.mask); this.overlays.push(t); return t; } clearOverlays() { for (const t of this.overlays) t.destroy(); this.overlays = []; } // ── Sticky wilds pinned over the glass ────────────────────────────────────── setSticky(cells, symbolId) { for (const s of this.stickyImgs) s.destroy(); this.stickyImgs = []; for (const { c, r } of cells) { const { x, y } = this.cellCenter(c, r); const img = this.scene.add.image(x, y, this.symKey(symbolId)) .setDepth(this.depth + 2); img.setDisplaySize(this.cellW * 0.92, this.cellH * 0.92); this.stickyImgs.push(img); } } destroy(fromShutdown = false) { this.scene.events.off('update', this.updateHandler); if (fromShutdown) return; // scene teardown destroys children itself for (const reel of this.reels) for (const img of reel.imgs) img.destroy(); this.clearOverlays(); this.setSticky([], null); this.frame.destroy(); this.shade.destroy(); this.maskG.destroy(); } }