283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
// 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();
|
|
}
|
|
}
|