import { Container } from 'pixi.js'; import { Disposable } from '../utils/Disposable.js'; import { ReelSetInternalConfig, CellBounds, SpinOptions, AnticipationStagger, AnticipationOptions } from '../config/types.js'; import { EventEmitter } from '../events/EventEmitter.js'; import { ReelSetEvents, SpinResult, RunCascadeResult as RunCascadeResultBase } from '../events/ReelEvents.js'; import { Reel, NudgeOptions } from './Reel.js'; import { ReelViewport } from './ReelViewport.js'; import { SpeedManager } from '../speed/SpeedManager.js'; import { SymbolSpotlight } from '../spotlight/SymbolSpotlight.js'; import { SymbolFactory } from '../symbols/SymbolFactory.js'; import { FrameBuilder, FrameMiddleware } from '../frame/FrameBuilder.js'; import { PhaseFactory } from '../spin/phases/PhaseFactory.js'; import { SpinningMode } from '../spin/modes/SpinningMode.js'; import { CellPin, CellPinOptions, MovePinOptions, CellCoord } from '../pins/CellPin.js'; import { ColumnTarget } from '../frame/ColumnTarget.js'; import { Cell } from '../cascade/tumbleAlgorithm.js'; export interface ReelSetParams { config: ReelSetInternalConfig; reels: Reel[]; viewport: ReelViewport; symbolFactory: SymbolFactory; frameBuilder: FrameBuilder; phaseFactory: PhaseFactory; spinningMode: SpinningMode; defaultSpinMode: 'standard' | 'cascade'; } /** * The runtime-mutable frame-builder pipeline exposed on `reelSet.frame`. * Matches `FrameBuilder.use/remove`. the internal machinery that already * exists; this is the ergonomic surface. */ export interface FrameAPI { /** Add a middleware. Sorted by `priority` on next frame build. */ use(middleware: FrameMiddleware): void; /** Remove a middleware by `name`. No-op if absent. */ remove(name: string): void; /** Current middleware list in registration order. */ readonly middleware: ReadonlyArray; } /** * Options for {@link ReelSet.destroySymbols}. Every field is optional; * the defaults produce the canonical "winners poof" look (no stagger, * no viewport dim, zIndex bumped to 1000 so destroy effects render * above neighbouring cells). */ export interface DestroySymbolsOptions { /** * Per-cell start delay in seconds. Default `0` (every cell starts together). * Pass `(cell, i) => i * 0.03` for a per-cell stagger. */ delay?: number | ((cell: Cell, index: number) => number); /** * zIndex applied to each cell's view for the duration of the animation * so destroy effects aren't clipped behind neighbours. Default `1000`. * The library does NOT restore the previous zIndex. the cell is * destroyed (alpha 0) and will be replaced on the next `refill()` / * `setResult()`. Pass `null` to skip the bump. */ zIndex?: number | null; /** * Dim the viewport (`viewport.showDim(alpha)`) while the destroy * animation runs, restoring on completion. Pass `false` to skip, * a number for a custom alpha. Default: `false` (no dim). */ dim?: boolean | number; /** * Abort signal. Aborting mid-destroy kills every in-flight * `playDestroy` tween and snaps the cells to their destroyed pose * (`alpha: 0`) without waiting for the natural end of the animation. * The returned promise still resolves normally. abort means * "fast-forward to the destroyed state," not "fail." Forwarded * automatically by `runCascade`'s own `signal`. */ signal?: AbortSignal; } /** * Summary returned by {@link ReelSet.runCascade}. Re-exported from the * canonical definition in `events/ReelEvents.ts` so the events module * stays the single source of truth for shared shapes. */ export type RunCascadeResult = RunCascadeResultBase; /** * Options for {@link ReelSet.refill}. The two required fields encode * what just happened (`winners`) and what the next visible state should * be (`grid`). Everything else is timing / cancellation / animation * flavor with sensible defaults. * * Use this directly when you're driving your own cascade loop. For the * common case of "destroy → refill → check → repeat", use * {@link ReelSet.runCascade} instead. it composes refill, destroySymbols, * and win-detection into one call with the same cancellation semantics. */ export interface RefillOptions { /** Winners that were just destroyed. Their cells will be refilled per gravity. */ winners: ReadonlyArray; /** * Target grid after refill. Convention: the top `winners.length` rows * per reel are new symbols falling in from above; the rest are * survivors in their original top-to-bottom order. Same contract as * the `nextGrid` callback in `runCascade`. */ grid: ColumnTarget[]; /** * Pick the refill animation flavor. * * - `'combined'` (default). survivors and new symbols animate * together in one drop-in beat. The Sweet Bonanza / Sugar Rush feel. * - `'gravity-then-drop'`. survivors slide down to fill holes FIRST, * then a global pause (`gravityHoldMs` + `gravityHold`), then new * symbols enter from above. Useful when you want a multiplier or * SFX beat between the two motions. */ mode?: 'combined' | 'gravity-then-drop'; /** * Fixed wall-clock pause (ms) between the gravity stage and the * drop-in stage. Only applies when `mode === 'gravity-then-drop'`. * Default `250`. Combines via `Promise.all` with `gravityHold` if * both are provided. whichever finishes LAST gates the drop-in. */ gravityHoldMs?: number; /** * Promise (or zero-arg factory) gating the drop-in stage. Only * applies when `mode === 'gravity-then-drop'`. * * - `Promise`. pass an already-in-flight animation / SFX / * network call's completion handle when you want the drop-in to * wait for it. The promise is awaited as-is. * - `() => Promise`. pass a factory when the *side effects* * of starting the promise should fire AT gravity-end, not at * refill-start. * * Combines via `Promise.all` with `gravityHoldMs`. pass both to * floor the hold to a minimum wall-clock duration even if the * promise resolves earlier. */ gravityHold?: Promise | (() => Promise); /** * Awaitable callback fired AFTER `gravityHoldMs` + `gravityHold` both * resolve, BEFORE the drop-in stage. Only fires when * `mode === 'gravity-then-drop'`. Use for last-mile side effects that * need to read the post-hold state. */ onGravityComplete?: () => Promise | void; /** * Abort signal. Aborting mid-refill slams the in-flight animation so * the await unblocks immediately rather than waiting for the drop-in * to finish. The returned promise still resolves normally with * `wasSkipped: true`. Mirrors the abort contract on * {@link RunCascadeOptions.signal} and {@link DestroySymbolsOptions.signal}. */ signal?: AbortSignal; } /** * Summary returned by {@link ReelSet.refill}. Symmetric with * {@link RunCascadeResult}. same vocabulary, narrower scope (one stage * vs. the full chain). */ export interface RefillResult { /** Number of winner cells that were refilled. Equals `winners.length`. */ winnersRefilled: number; /** Visible grid after the refill landed. Matches `getVisibleGrid()`. */ finalGrid: string[][]; /** True if the refill was aborted via `signal` (slammed to land). */ wasSkipped: boolean; /** Total refill duration in milliseconds. */ duration: number; } /** * Options for {@link ReelSet.runCascade}. The two required callbacks * (`detectWinners`, `nextGrid`) encode game rules; everything else is * timing / cancellation / forwarded-to-destroy plumbing with sensible * defaults. */ export interface RunCascadeOptions { /** * Win-detection callback. Receives the current grid (a fresh copy from * `getVisibleGrid()`) and the chain level (0 on the first iteration). * Returns the cells whose symbols are "winners" that should be cleared * before the next refill. Return `[]` to end the chain. Sync or async. */ detectWinners: (grid: string[][], chainLevel: number) => readonly Cell[] | Promise; /** * Next-grid callback. Given the post-destroy grid and the winners that * were cleared, return the grid the survivors + new symbols should * land on. This is your server-side gravity simulation (or the * fallback `cascadeNextGrid` from your client). Sync or async. * * Must follow the gravity convention: top `winners.length` rows per * reel are new symbols; the rest are survivors in original top-to- * bottom order. Same contract as `refill({ grid })`. * * May return `string[][]` (visible cells only) or `ColumnTarget[]` * (when the next grid places anchors in `bufferAbove` / `bufferBelow`). */ nextGrid: (grid: string[][], winners: readonly Cell[], chainLevel: number) => string[][] | ColumnTarget[] | Promise | Promise; /** * Win-presentation hook fired AFTER detection (`cascade:chain:start`) * and BEFORE `destroySymbols`. the beat where the winners are still on * the board. This is where a `WinPresenter` pass belongs: play the * authored win clip, dim the losers, await, and only then does the * library destroy the cells. A round's presentation order is * win → destroy → refill; `onCascade` remains the post-destroy hook. * * - `chain`. same 1-indexed chain stage as `cascade:chain:start`. * - `winners`. cells about to be destroyed. still visible. * - `currentGrid`. the grid as it stood at `cascade:chain:start`. */ presentWinners?: (info: { chain: number; winners: readonly Cell[]; currentGrid: string[][]; }) => void | Promise; /** * Per-cascade hook fired AFTER `destroySymbols` and BEFORE the refill * starts. Use it to bump multipliers, play SFX, run "winners gone" * UI animations. Return a promise to delay the refill (e.g. for a * number-roll animation). * * - `chain`. same 1-indexed chain stage as `cascade:chain:start`. * - `winners`. cells that were just destroyed. * - `currentGrid`. the grid as it stood at `cascade:chain:start` * (same reference). The symbols at `winners` are visually gone but * the grid array still names them. `nextGrid` will replace them. */ onCascade?: (info: { chain: number; winners: readonly Cell[]; currentGrid: string[][]; }) => Promise | void; /** * Milliseconds to wait between win-destroy completing and the next * refill starting. Commercial slots dial this between 150 ms (snappy) * and 500 ms (dramatic). Default `250`. */ pauseAfterDestroyMs?: number; /** * Safety cap on cascade-chain length. Defaults to `32`. a sane * upper bound that protects against pathological server bugs while * being well above any commercial slot's natural cap. Pass `Infinity` * to disable. */ maxChain?: number; /** * Forwarded to `destroySymbols(cells, opts)` on every cascade. Useful * for per-cell stagger, viewport dim, an abort signal, etc. */ destroyOptions?: DestroySymbolsOptions; /** * How each refill in the chain animates. * * - `'combined'` (default). survivors and new symbols animate * together in one drop-in beat. The Sweet Bonanza / Sugar Rush feel. * - `'gravity-then-drop'`. survivors slide down to fill holes FIRST, * then a global pause (`gravityHoldMs`), then new symbols enter * from above with the per-reel stop delay applied. The Mummyland * Treasures / Reactoonz feel. gives space for an anticipation * beat between gravity and new-symbol entry. * * Per-column stagger inside the new-symbol drop is controlled by * `setDropOrder('ltr', stepMs)` exactly as in combined mode. when the * step is shorter than `dropIn.duration` you get overlapping waves; * when it's at least as long you get strictly sequential columns. */ refillMode?: 'combined' | 'gravity-then-drop'; /** * Fixed wall-clock pause between gravity end and drop-in start, in ms. * Only used when `refillMode === 'gravity-then-drop'`. Default `250`. * Combines via `Promise.all` with `gravityHold` if both are provided. * * The natural place for asymmetric anticipation visuals: register a * listener on `cascade:gravity:end` (one per reel) and trigger your * mascot / multiplier roll / SFX from there. Use `gravityHold` if you * already have an in-flight animation promise, or `onGravityComplete` * if you need a post-hold callback. */ gravityHoldMs?: number; /** * Per-cascade promise-builder. Invoked once per chain stage at the * **gravity-end boundary** (i.e. AFTER every reel's gravity stage has * settled, just before the global hold begins). The returned promise * is awaited in parallel with `gravityHoldMs` via `Promise.all`. * whichever finishes LAST gates the drop-in. Only fires when * `refillMode === 'gravity-then-drop'`. * * Use this when each cascade starts its own anticipation animation * (multiplier roll, mascot reaction, anticipation SFX) and you want * the builder's *side effects* (e.g. `multiplier.bumpTo(chain + 1)`) * to fire AT gravity-end. not back when the refill args were * assembled. The library calls your function at the right beat and * awaits the promise you return. * * - `chain`. same 1-indexed chain stage as `cascade:chain:start`. * - `winners`. cells cleared this cascade. * * A rejection from the returned promise is surfaced via the * `cascade:gravity:error` event AND logged via `console.error`; the * engine slams the refill so the awaited promise still settles. */ gravityHold?: (info: { chain: number; winners: readonly Cell[]; }) => Promise; /** * Per-cascade callback fired AFTER `gravityHoldMs` + `gravityHold` both * resolve, BEFORE the drop-in stage. Only fires when * `refillMode === 'gravity-then-drop'`. Use for last-mile side effects * that need to read post-hold state (e.g. snapshot the multiplier * value that just finished its count-up). * * - `chain`. same 1-indexed chain stage as `cascade:chain:start`. * - `winners`. cells cleared this cascade. */ onGravityComplete?: (info: { chain: number; winners: readonly Cell[]; }) => Promise | void; /** * Abort signal for caller-driven cancellation. The loop exits at the * next await boundary, the in-flight refill (if any) is slammed via * `slamStop()`, and the resolved summary reports `wasSkipped: true`. * * Use this for "player tapped SLAM mid-cascade". `reelSet.skipSpin()` is * a no-op when called between refills (the engine is idle), so it * cannot end the chain from a button handler. AbortController can. * * ```ts * const controller = new AbortController(); * skipButton.addEventListener('click', () => controller.abort()); * await reelSet.runCascade({ ..., signal: controller.signal }); * ``` */ signal?: AbortSignal; } /** * The whole slot board as one object. * * A `ReelSet` is a PixiJS `Container` that owns every reel, the spin * controller, the speed manager, and the win spotlight. You `addChild` it * to your stage and then drive it from the four public verbs below: * * - `spin()`. start the reels moving, returns a promise that resolves * when every reel has landed (or been slam-stopped) * - `setResult(grid)`. tell the reels what to land on; the spin * controller consumes this and each reel queues its target symbols * - `setAnticipation(reelIndices)`. slow the given reels before they * stop, for "will the third scatter land?" tension * - `skipSpin()` lands the in-flight spin immediately. The slam-stop button calls this. * * Everything else is subsystems: `speed`, `spotlight`, `events`, `viewport`. * Construction goes through {@link ReelSetBuilder}, never `new ReelSet()` * directly. the builder enforces that every required piece is wired. * * ```ts * const reelSet = new ReelSetBuilder() * .reels(5).visibleRows(3).symbolSize(140, 140) * .symbols((r) => r.register('cherry', SpriteSymbol, { textures })) * .ticker(app.ticker) * .build(); * app.stage.addChild(reelSet); * * const spin = reelSet.spin(); * reelSet.setResult(await server.spin()); * await spin; * ``` * * Teardown cascades: one `reelSet.destroy()` disposes every child. */ export declare class ReelSet extends Container implements Disposable { /** * zIndex applied to pin overlays so they render above the reel strip. * * **The library's z-index budget**, for reference if you author symbols * that need to layer above defaults: * * | Layer | zIndex | Source | * |---|---|---| * | 1×1 symbol, default | `0 * 100 + arrayIndex` (~0–10) | `symbolData.zIndex ?? 0` | * | 1×1 symbol, elevated (`zIndex: 1` on `symbolData`) | `1 * 100 + arrayIndex` (~100) | `symbolData.zIndex` | * | Big-symbol anchor, default registration | `5 * 100 + arrayIndex` (~500) | recipe convention | * | Pin overlay (sticky/expanding wild during spin) | `10000` | `PIN_OVERLAY_Z_INDEX` | * * The 100× multiplier on `symbolData.zIndex` leaves room for per-row * stacking inside a layer (bottom rows render in front of top rows on * the same layer). The 10000 ceiling on pin overlays is set very high * so a consumer who sets `symbolData.zIndex: 50` (= 5000) still sits * below pins. If you need to stack ABOVE pin overlays. e.g. a win- * presenter symbol promotion. re-parent the symbol to * `viewport.spotlightContainer`, which is its own DisplayObject layer * above pin overlays. */ private static readonly PIN_OVERLAY_Z_INDEX; private _events; private _reels; private _viewport; private _spinController; private _speedManager; private _spotlight; private _symbolFactory; private _frameBuilder; private _frameAPI; private _isDestroyed; private _pins; /** * Visual overlays rendered above the reel viewport while a spin is in * motion. Each overlay is a pooled ReelSymbol sitting in the viewport's * unmaskedContainer at the pin's cell position. it keeps the pinned * symbol visible while the underlying reel scrolls. Created on * spin:start, destroyed on spin:allLanded. The pin is co-stored so * _destroyPinOverlay always has it available even after the pin is * removed from `_pins` (e.g. during unpin()). */ private _pinOverlays; /** * MultiWays: target row counts for the next AdjustPhase. Recorded by * `setShape()`, consumed by `SpinController` when it builds AdjustPhase * configs. `null` means "no shape change pending". */ private _targetShape; /** * True once `setResult()` has been called for the current spin. Reset on * every `spin:start`. Used to enforce the contract that `setShape()` * must be called BEFORE `setResult()`. calling it after corrupts the * cached frames (pins were applied at their pre-migration rows; a later * setShape would migrate them but the frames are already built). */ private _resultSetForCurrentSpin; /** Set at construction by the builder when `.multiways(...)` was called. */ private _isMultiWaysSlot; /** * Number of `nudge()` calls currently in flight. Reference-counted (not a * boolean) so parallel nudges across reels don't clear the guard early: the * first to settle would otherwise let spin/setResult/pin/setShape race a * still-live nudge. Used by those methods to throw while any nudge runs. */ private _nudgesInFlight; private _multiwaysMinRows; private _multiwaysMaxRows; private _multiwaysReelPixelHeight; /** Resolved per-symbol metadata (size, zIndex, etc). */ private _symbolsData; /** Horizontal symbol gap (px). Used by `getBlockBounds` for big symbols. */ private _configGapX; constructor(params: ReelSetParams); /** The event emitter for reel-specific events. */ get events(): EventEmitter; /** * Start spinning. Returns a promise that resolves when all (non-held) * reels land. * * Pass `{ holdReels: [i, ...] }` to keep specific columns frozen for * this spin. they skip START / SPIN / STOP entirely and stay on * whatever symbols they're currently showing. The use cases are * Hold & Win respins, sticky / expanding wilds, and "the trigger * column stays in place" bonus rounds. * * Pass `{ mode: 'standard' | 'cascade' }` to override the builder-time * default for a single spin (e.g. classic strip-spin on the first round, * drop-in on the cascade waves). `'cascade'` requires `.tumble(...)` * on the builder. * * @example * // Plain spin. every reel animates. * await reelSet.spin(); * * @example * // Hold reels 0 and 4; only the middle three reroll. * const spin = reelSet.spin({ holdReels: [0, 4] }); * reelSet.setResult(serverGrid); // entries at 0/4 are ignored * await spin; * * @example * // Per-spin cascade override. * await reelSet.spin({ mode: 'cascade' }); * * See {@link SpinOptions} for the full contract (event behaviour, * setResult interaction, setAnticipation filtering). */ spin(options?: SpinOptions): Promise; /** * Set the target result symbols. Triggers the stop sequence. * * One `ColumnTarget` per reel. `visible` is the visible-window target; * optional `bufferAbove` / `bufferBelow` target cells outside it. * * If any pins are active (`reelSet.pin(...)`), their symbols are overlaid * onto the result before it reaches the stop sequencer, so pinned cells * always land on the pin's `symbolId` regardless of what the server sent. * * @example * reelSet.setResult([ * { visible: ['A','B','C'] }, * { visible: ['A','B','C'] }, * { visible: ['A','B','C'], bufferAbove: ['COIN'] }, * { visible: ['A','B','C'] }, * { visible: ['A','B','C'] }, * ]); */ setResult(symbols: ColumnTarget[]): void; /** * Tumble cascade: cascade refill (Moment B). Call this AFTER you've faded * out the winning symbols in your own code, with the list of winner cells * and the next grid the server returned. * * - Untouched survivors don't animate. * - Survivors above a hole slide down to fill it. * - New symbols enter from above into the top `winners.length` rows * of each reel. * * The new grid must follow the gravity convention: per reel, the top * `winnerRows.length` rows are the new symbols, the remaining rows are * survivors in their original top-to-bottom order. This matches what * server-side gravity simulations emit. * * Resolves with a {@link RefillResult} (mirror of {@link RunCascadeResult}. * one stage's worth). Requires the builder to have been configured with * `.tumble(...)`. * * For the common destroy → refill → check → repeat loop, prefer * {@link ReelSet.runCascade}. it composes refill, destroySymbols, and * win-detection with the same cancellation semantics. * * @example * const winners = detectWins(currentGrid); * await reelSet.destroySymbols(winners); * const next = await server.cascade(winners); * const result = await reelSet.refill({ winners, grid: next }); * console.log(result.finalGrid, result.wasSkipped); * * @example * // Abort mid-refill: slams the in-flight animation, resolves with wasSkipped. * const ac = new AbortController(); * skipButton.onclick = () => ac.abort(); * const result = await reelSet.refill({ * winners, grid: next, signal: ac.signal, * }); */ refill(opts: RefillOptions): Promise; /** * Destroy a batch of cells in parallel, deferring to each symbol's own * `playDestroy()` so subclasses (Spine, particles, custom sprites) can * provide art-appropriate disintegration without the spin handler caring. * * This is the canonical "fade out the winners" step in a cascade chain: * call it between win-detection and `refill()`. Every cell's view is * lifted with a high zIndex so the destroy animation isn't clipped by * neighbours. The default `playDestroy` is a brief scale/fade implode; * override it per symbol class for art-appropriate destruction. * * - Empty `cells` resolves immediately, no work. * - Out-of-range cells throw. the contract is that you've already * run win detection on the visible grid, so coords must be valid. * * @example * const winners = detectWinners(reelSet.getVisibleGrid()); * await reelSet.destroySymbols(winners); * await reelSet.refill({ winners, grid: nextGrid }); * * @example * // Per-cell stagger. disintegrate left-to-right with a 30 ms beat. * await reelSet.destroySymbols(winners, { * delay: (cell, i) => i * 0.03, * }); */ destroySymbols(cells: ReadonlyArray, opts?: DestroySymbolsOptions): Promise; /** * Run the canonical cascade chain on top of `refill()`. Loops: * detect winners → destroy → pause → refill → emit. until * `detectWinners` returns an empty list (or `maxChain` is hit, or the * player slammed via `skipSpin()` / abort). Resolves with the final grid * and a summary. * * The orchestration is library-owned; the **game rules** (what counts * as a winner, how the next grid is computed) stay in your callbacks. * This is the cascade equivalent of `spin()` + `setResult()`. three * lines instead of fifteen, and the slam path is handled for you. * * Typical usage: * * ```ts * await reelSet.spin(); * reelSet.setResult(await server.spin()); * const summary = await reelSet.runCascade({ * detectWinners: (grid) => detectClusters(grid), * nextGrid: async (grid, winners) => server.cascade(winners), * onCascade: ({ chain, winners }) => bumpMultiplier(chain), * }); * console.log(summary.chainLength, summary.totalWinners); * ``` * * Composes with everything else in the library: * - `setDropOrder(...)` is honoured on every refill in the chain. set * it before `runCascade` and the same order applies to every drop. * - `cascade:fall:symbol`, `cascade:place:end`, `cascade:dropIn:symbol` * fire on each refill. * - `reelSet.skipSpin()` ends the chain immediately; the returned summary * reports `wasSkipped: true`. * * Event order per stage with winners: `cascade:chain:start` → * `cascade:destroy:start` → (destroy tweens) → `cascade:destroy:end` → * `onCascade` → pause → refill (`cascade:place:end` + * `cascade:dropIn:*` per reel) → `cascade:chain:end`. The chain itself * is delimited by the returned `Promise`. `await` the call to know * when it's done. * * Requires `.tumble(...)` on the builder (same as `refill()`). */ runCascade(opts: RunCascadeOptions): Promise; /** * Set which reels should show anticipation before stopping, and how their * slow-downs are spaced via `stagger`: * * - `0` (default): every anticipation reel begins slowing at once (the * historical parallel behaviour). * - `number`: reel at tease-order `k` starts its slow-down `k * stagger` * ms after the first, so the tease sweeps across the reels. * - `number[]`: explicit per-tease-order offset in ms. * - `'sequential'`: each reel waits until the previous anticipation reel * has fully landed before it starts. maximal one-at-a-time tension. * * Offsets are by tease-order (position in `reelIndices`), not raw reel * index. Reset at the start of every `spin()`. * * Pass a `{ stagger, slowdown, duration }` object to shape the tease more: * - `slowdown` interpolates across the tease sequence so each successive * reel slows to a lower speed (`from` → `to`) and/or holds longer * (`holdFrom` → `holdTo`) — the escalating "each reel crawls slower than * the last" build-up. See {@link AnticipationSlowdown}. * - `duration` (ms) overrides the profile's `anticipationDelay`, so the * tease plays even in Turbo / SuperTurbo (whose profiles use * `anticipationDelay: 0` and would otherwise skip anticipation). * * Listen to `anticipation:reel` ({ reelIndex, order, total }) to drive * per-step tension SFX / a pitch ramp, and `anticipation:reelEnd` to stop it. * * @example * // Classic "2 scatters showing" sweep across the last three reels: * reelSet.setResult(grid); * reelSet.setAnticipation([2, 3, 4], 450); // 450ms apart * reelSet.setAnticipation([2, 3, 4], 'sequential'); // strict one-by-one * * // Keep the tease alive in turbo (profile anticipationDelay is 0): * reelSet.setAnticipation([2, 3, 4], { duration: 350, stagger: 200 }); * * // Escalating slow-down: later reels crawl slower and hold longer. * reelSet.setAnticipation([2, 3, 4], { * stagger: 400, * slowdown: { from: 0.45, to: 0.12, holdTo: 2 }, * }); * * // Drive which reels tease straight from the result grid: * const reels = anticipationForScatters(grid, { symbol: 'SCAT', trigger: 2 }); * reelSet.setAnticipation(reels, { stagger: 'sequential', slowdown: { from: 0.4, to: 0.1 } }); */ setAnticipation(reelIndices: number[], options?: AnticipationStagger | AnticipationOptions): void; /** * Override the per-reel stop delay (in ms). Pass one value per reel. * * **Sticky.** The override persists indefinitely. it survives across * `spin()` AND `refill()` boundaries until you call `setStopDelays()` * (or `setDropOrder()`) again. The persistence is deliberate: cascade * recipes that set `setDropOrder('all')` once before `runCascade(...)` * want every internal `refill()` to honor it. If your rounds use * different patterns, re-set explicitly per round. * * Pass `null` to CLEAR the override and restore the default * `i * speed.stopDelay` stagger. this is distinct from `[]` / all-zeros * (which lands every reel simultaneously). Use it to undo a one-off * per-round pattern without hard-coding the default back in. * * @example * // Stagger the last two reels more than the default for dramatic effect: * reelSet.setStopDelays([0, 140, 280, 600, 1100]); * // ...later, go back to the profile default: * reelSet.setStopDelays(null); */ setStopDelays(delays: number[] | null): void; /** * Round-aware spin skip. The button-press entry point. The first press * in a round slams the current drop AND applies a round-scoped side * effect: * * - Standard mode: boost the active speed profile to the fastest * registered one (emits `skip:boosted`). Restored on the next * `spin()` (unless the app manually changed speed in between). * - Cascade/tumble mode: flag every subsequent `refill()` to * auto-slam with no animation. One press ends a multi-drop cascade. * * Subsequent presses also slam each current drop. * * Throws if called before `setResult()` arrives (nothing to land on: * slamming now would land on random spin-buffer content). The universal * "spin/skip" button pattern should call `requestSkip()` in that window * (or wrap `skipSpin()` in a try/catch that routes to `requestSkip()` * in the catch). Callers that want a slam without the round-scoped side * effects (tests, anti-cheat) should use `slamStop()`. * * Pairs with `skipNudge()` (skip an in-flight `nudge()`) and `slamStop()` * (unconditional land-now, no boost). Three distinct actions: * * - `skipSpin()` lands the in-flight spin and applies the round-scoped * boost / auto-slam-refills side effect. * - `skipNudge()` fast-forwards an in-flight `nudge()` to its landed * position. Spin state is unrelated. * - `slamStop()` lands every un-landed reel unconditionally. No boost. */ skipSpin(): void; /** * Slam-stop safe before `setResult()` arrives: queues until then. * Bypasses the two-stage `skipSpin()` machine. An explicit slam intent. * * Note on `skipStage`: when this call queues a slam (pre-`setResult`) * rather than firing one, `skipStage` stays at `0` until `setResult()` * arrives and the queued slam actually runs. If your UI labels the * button off `skipStage`, expect a beat of "Skip" still shown while * the queued intent is in flight; the queued state is not exposed as * its own stage on purpose (kept the `0 | 1 | 2` shape stable). */ requestSkip(): void; /** * Hard slam-stop. Always lands every un-landed reel immediately. * Bypasses the two-stage `skipSpin()` machine and any speed boost. * For tests, anti-cheat flows, or any caller with unambiguous * "end now" intent. * * Pairs with `skipSpin()` (round-aware land + boost) and `skipNudge()` * (fast-forward an in-flight `nudge()`). */ slamStop(): void; /** * Current `skipSpin()` position within the active round. `0` until the * player presses the slam button, `2` after. Read this to drive button * labels (e.g. "Skip" to "Skipped"). `1` is reserved for forward compat * and is not currently reachable. * * `requestSkip()` that gets queued pre-`setResult()` does NOT advance * the stage until the queued slam actually fires (i.e. once * `setResult()` arrives). If you need a "queued" UI state, track that * yourself alongside `skipStage`. */ get skipStage(): 0 | 1 | 2; /** * Swap the symbol at a single grid cell in-place, at rest. * * Caller-facing wrapper over `Reel.setSymbolAt` that ALSO refuses * pinned cells (since `Reel` itself can't see the pin map). Use this * for live presentation effects. sticky-after-win, mid-feature * rewrites. without going through `setResult()`. * * Throws (in addition to the per-reel guards documented on * `Reel.setSymbolAt`) if `(col, row)` currently has an active pin. * Use `unpin(col, row)` first if you intentionally want to overwrite it. * * @example * await reelSet.spin(); // landed * reelSet.setSymbolAt(2, 1, 'wild'); // swap centre cell to wild */ setSymbolAt(col: number, row: number, symbolId: string): void; /** * Shift a single reel by `distance` positions after it has landed, revealing * caller-supplied symbols. * * Per-reel by design. multi-reel sync is via `Promise.all([...])` of * independent calls. Each call emits its own `nudge:start` / `nudge:complete` * pair on the ReelSet bus and `phase:enter('nudge')` / `phase:exit('nudge')` * on the per-reel bus. * * Big-symbol blocks on the target reel are nudged through as a unit as * long as they fit on the strip post-rotation. Use case: a 1xH block * lands with stubs in bufferBelow; nudge up to reveal it fully. * * `nudge:start` fires AFTER pre-placement so listeners observe the * about-to-tween state, mirroring `nudge:complete` which fires after * the strip has snapped. To capture the pre-nudge state, snapshot the * grid in your call site before awaiting. * * Throws (synchronously) if: * - the reel set is currently spinning (avoid races with the spin pipeline), * - `col` is out of range, * - any visible cell on the target reel has an active pin, * - `Reel.nudge` itself rejects (bad distance / direction / incoming / * incompatible big-symbol layout). * * While `nudge()` is in flight, calling `spin()`, `setResult()`, `pin()`, * or `setShape()` throws. Await the returned promise before calling any * of those methods. * * Rejects with an `AbortError` if `options.signal` aborts or the reel * is destroyed mid-tween. `nudge:cancelled` fires on the bus in that case. * * @example * await reelSet.spin(); // landed * await reelSet.nudge(2, { distance: 1, direction: 'down', incoming: ['wild'] }); * * @example Parallel nudges across two reels: * await Promise.all([ * reelSet.nudge(2, { distance: 1, direction: 'down', incoming: ['wild'] }), * reelSet.nudge(3, { distance: 1, direction: 'down', incoming: ['wild'] }), * ]); * * @example Staggered parallel via `startDelay`: * await Promise.all( * [1, 2, 3].map((col, i) => * reelSet.nudge(col, { ...opts, startDelay: i * 80 }), * ), * ); * * @example Abortable nudge: * const controller = new AbortController(); * skipButton.onclick = () => controller.abort(); * await reelSet.nudge(2, { ...opts, signal: controller.signal }) * .catch((e) => { if (e.name !== 'AbortError') throw e; }); */ nudge(col: number, options: NudgeOptions): Promise<{ symbols: string[]; }>; private _assertNoNudgeInFlight; /** * Fast-forward an in-flight `nudge()` to its landed state. No-op if the * given reel is not currently nudging. * * The tween's `onComplete` fires synchronously, the strip snaps to the * final position, and the original `nudge()` promise resolves on the * next microtask. `nudge:complete` fires normally. From a listener's * POV the nudge just landed fast. * * Pairs with `skipSpin()` (round-aware spin land + boost) and * `slamStop()` (unconditional spin land-now). These three are distinct: * spin actions do not affect a nudge in flight, and `skipNudge` does * not touch spin state. * * @param col Reel index, or `undefined` to skip all in-flight nudges. */ skipNudge(col?: number): void; /** * Set the per-reel drop order for the next stop / refill sequence. * * Convenience wrapper over `setStopDelays()` for common patterns. The * stagger step defaults to the active speed profile's stopDelay (or * 150 ms if stopDelay is 0). * * **Sticky.** The override persists indefinitely. until another * `setDropOrder()` / `setStopDelays()` call overwrites it (a `null` / * absent override falls back to the default `i * speed.stopDelay` * stagger). It survives across `spin()` AND `refill()` boundaries by * design, because `runCascade(...)` calls `refill()` in a loop and the * order set once before the chain must apply to every iteration. * * The canonical cascade pattern resets it per phase: * * - `setDropOrder('ltr')` before `spin()`. left-to-right reveal on * the initial drop. * - `setDropOrder('all')` before `runCascade()`. every refill in the * chain drops all columns simultaneously (the commercial-cascade * pattern). * * If you leave the order set between rounds and don't re-set before the * next `spin()`, the previous value carries over. Re-set explicitly per * round if your rounds use different patterns. * * Call again with a different value to change it; the previous value * is replaced, not stacked. * * @example * reelSet.setDropOrder('ltr'); // left-to-right * reelSet.setDropOrder('rtl'); // right-to-left * reelSet.setDropOrder('all'); // all columns simultaneously * reelSet.setDropOrder([0, 0, 200, 200, 400]); // custom per-reel delays * reelSet.setDropOrder(null); // clear the override, restore the default */ setDropOrder(order: 'ltr' | 'rtl' | 'all' | number[] | null, stepMs?: number): void; get isSpinning(): boolean; /** Whether this slot was built with `.multiways(...)`. */ get isMultiWaysSlot(): boolean; /** * MultiWays: record the row count each reel should land on this spin. The * AdjustPhase between SPIN and STOP will reshape reels (resize symbols, * reshape motion) before the stop sequence runs. * * Must be called between `spin()` and `setResult()`. The shape stays in * effect for the current spin only. call again on every spin. * * Throws if: * - this slot was not built with `.multiways(...)` * - `rowsPerReel.length !== reelCount` * - any entry falls outside `[multiways.minRows, multiways.maxRows]` */ setShape(rowsPerReel: number[]): void; /** Wired internally by SpinController. Consumers do not call this directly. */ private peekTargetShape; /** Wired internally by SpinController. Consumers do not call this directly. */ private clearTargetShape; /** * Resolved grid, with all OCCUPIED cells (same-reel and cross-reel) * replaced by their anchor's symbol id. A 2×2 bonus reads as four * `'bonus'` cells. * * Equivalent to `reelSet.reels.map(r => r.getVisibleSymbols())` because * each reel has a cross-reel resolver wired in by ReelSet's constructor. * the per-reel surface and the grid surface are the same. */ getVisibleGrid(): string[][]; /** * Footprint of the symbol at `(col, row)`. * * - 1×1 symbols: `{ anchor: { col, row }, size: { w: 1, h: 1 } }`. * - Big symbols: returns the anchor cell and block size. * - OCCUPIED cells: resolves transparently to the anchor. * * Useful for win presenters that need to highlight a whole NxM block. */ getSymbolFootprint(col: number, row: number): { anchor: { col: number; row: number; }; size: { w: number; h: number; }; }; /** * Pixel rectangle covering a big symbol's whole `N×M` block, in * ReelSet-local coordinates. Returns the anchor cell's bounds for 1×1 * symbols. Pass any cell of a block. anchor or non-anchor. and you * get the same rect. * * Useful for win presenters drawing an outline around a whole bonus, or * any overlay aligned to the visible footprint of a big symbol: * * ```ts * const rect = reelSet.getBlockBounds(2, 1); * gfx.rect(rect.x, rect.y, rect.width, rect.height) * .stroke({ color: 0xff6b35, width: 4 }); * reelSet.addChild(gfx); * ``` * * For 1×1 cells this is equivalent to `getCellBounds(col, row)`. For * big-symbol cells it multiplies width/height by the block size and * starts from the anchor cell's bounds. */ getBlockBounds(col: number, row: number): CellBounds; /** Speed profile manager. */ get speed(): SpeedManager; /** Change speed and emit event. */ setSpeed(name: string): void; get spotlight(): SymbolSpotlight; /** Get all reels. */ get reels(): readonly Reel[]; /** Get a reel by index. */ getReel(index: number): Reel; /** * Returns the bounding box of a visible grid cell in ReelSet-local * coordinates (i.e. relative to this Container, before any parent * transforms). Row 0 is the top visible row. * * Use this to place payline graphics, hit areas, or debug overlays * that must align with a specific symbol cell: * * ```ts * const b = reelSet.getCellBounds(2, 1); * gfx.rect(b.x, b.y, b.width, b.height).stroke({ color: 0xff6b35 }); * reelSet.addChild(gfx); * ``` * * To convert to stage / global coordinates use PixiJS: * ```ts * const global = reelSet.toGlobal({ x: b.x, y: b.y }); * ``` */ getCellBounds(col: number, row: number): CellBounds; /** Get the viewport. */ get viewport(): ReelViewport; /** * Pin a symbol to a grid cell. Applied immediately if the reel is idle; * applied at the next `setResult()` otherwise. Fires `pin:placed`. * * Passing the same `(col, row)` replaces the previous pin. The old one * is replaced silently (no `pin:expired` fires for replacement). * * Negative rows are rejected. Place buffer-row anchors via `setResult()` * with `bufferAbove` / `bufferBelow` on the column's `ColumnTarget`. * * @example * // Sticky wild for 3 spins * reelSet.pin(2, 1, 'wild', { turns: 3 }) * * // Hold & Win coin with a payout value * reelSet.pin(col, row, 'coin', { turns: 'permanent', payload: { value: 50 } }) * * // Expanding wild: fill column for the current spin's evaluation only * for (let r = 0; r < 3; r++) reelSet.pin(2, r, 'wild', { turns: 'eval' }) */ pin(col: number, row: number, symbolId: string, options?: CellPinOptions): CellPin; /** * Remove a pin at `(col, row)`. If no pin exists at that cell, this is a * no-op. Fires `pin:expired` with reason `'explicit'`. */ unpin(col: number, row: number): void; /** * All active pins, keyed by `"col:row"`. * * Reads are safe at any time. during a spin the map reflects pins that * will apply to the NEXT `setResult()`, not the one already in flight. */ get pins(): ReadonlyMap; /** Convenience: get the pin at `(col, row)` or `undefined`. */ getPin(col: number, row: number): CellPin | undefined; /** * Move an existing pin from one cell to another. Animates a flight symbol * between the two cells, updates pin state atomically, and resolves when * the animation completes. * * This is the engine-native replacement for ghost sprites in walking-wild * recipes. The flight symbol is a pooled `ReelSymbol` acquired from the * factory, parented briefly to the viewport's `unmaskedContainer` so it * can travel across reel boundaries without being clipped. * * Constraints: * - Only callable at rest (throws if `isSpinning === true`). * - `to` must be within the grid; no pin may already exist there. * - Calling with `from === to` is a no-op that still fires `pin:moved`. * * @example * // Walking wild. move the pinned wild one column left each spin * reelSet.events.on('spin:complete', async () => { * for (const pin of [...reelSet.pins.values()]) { * if (pin.col > 0) { * await reelSet.movePin( * { col: pin.col, row: pin.row }, * { col: pin.col - 1, row: pin.row }, * ); * } else { * reelSet.unpin(pin.col, pin.row); * } * } * }); */ movePin(from: CellCoord, to: CellCoord, opts?: MovePinOptions): Promise; /** * Runtime-mutable middleware pipeline for symbol-frame generation. * * @example * // Feature entry. swap to a middleware that injects more wilds * reelSet.frame.use(moreWildsMiddleware); * * // Feature exit * reelSet.frame.remove('more-wilds'); */ get frame(): FrameAPI; get isDestroyed(): boolean; destroy(): void; /** * Overlay active pins onto `symbols`. Mutates the input in place; call * `_cloneTargets` first if the caller needs to keep the original * unmodified. */ private _applyPinsToGrid; /** * Deep clone a `ColumnTarget[]` so the caller can mutate the result * without touching the original. Inner arrays are spread (one level deep); * cells are strings, so further depth is not needed. */ private _cloneTargets; /** Pins on a given reel, in row order. Used by AdjustPhase migration. */ private _pinsOnReel; /** * MultiWays: relocate pins on a reel for a new visible-row count. The new * row is computed as `min(originRow, newRows - 1)`. clamped only when * the origin no longer fits. Returns the migrated pins so AdjustPhase * can build tween descriptors. Mutates the pins map in place. */ private _migratePinsForReel; /** * The on-screen Y of a pin overlay sitting on cell `row` of `reel`, given the * cell pitch `slotHeight`. Single source of truth for overlay placement; * `getSymbolAt(row).view.y` equals `row * slotHeight` for a snapped reel * (ReelMotion lays symbols at that pitch), so all overlay sites agree. */ private _pinOverlayCellY; /** * Apply a pin to the idle reel's visible display immediately. Used when * `pin()` is called while no spin is in flight. the grid updates right * away so `getVisibleSymbols()` reflects the pin. */ private _applyPinVisually; /** * Fires on `spin:allLanded`. Destroys visual pin overlays (the actual reel * cells now show the pinned symbols via setResult overlay), then * decrements numeric-turns pins and expires pins that hit zero. */ private _onSpinLanded; /** * Fires on `spin:start`. Clears every `'eval'` pin from the previous spin, * then creates a visual overlay for every remaining pin so its symbol * stays visible while the reel scrolls underneath. */ private _onSpinStart; /** * Create an overlay ReelSymbol for a pin in the viewport's unmasked * container. No-op if one already exists at that cell. Fires * `pin:overlayCreated` after the overlay is positioned and added to the * display list. that's the hook consumers use to drive animation state * (e.g. setting a Spine track). */ private _ensurePinOverlay; /** * Reposition + resize every pin overlay on the given reel. * * The engine calls this automatically after every MultiWays AdjustPhase * reshape (and from the skip path), so applications that just use * `setShape()` / `setResult()` never need to invoke it. **Call it * yourself only if** you mutate `Reel.symbolWidth`, `Reel.symbolHeight`, * or a pin's row outside the normal MultiWays flow. e.g. a custom * mid-spin layout swap that bypasses `AdjustPhase`. * * No-op for reels with no active pin overlays. */ refreshPinOverlaysForReel(reelIndex: number): void; /** * Internal: build AdjustPhase pin-overlay tween descriptors for a reel. * Captures the overlays' CURRENT on-screen Y + size as the tween's * `from` state, then computes the post-reshape `to` state from the * pin's already-migrated row + the upcoming cell height. Called BEFORE * AdjustPhase commits the reshape, so the snapshot reflects what the * player actually sees. */ private _buildPinOverlayTweens; /** * Destroy a single pin's overlay, if present. Fires * `pin:overlayDestroyed` BEFORE the overlay is released to the pool, so * consumers can stop animations / remove listeners on a still-valid * instance. */ private _destroyPinOverlay; /** Destroy every active pin overlay. Called on spin land and on destroy. */ private _destroyAllPinOverlays; } //# sourceMappingURL=ReelSet.d.ts.map