import { Ticker } from 'pixi.js'; import { Reel } from '../core/Reel.js'; import { AnticipationOptions, AnticipationStagger, SpinOptions, SymbolData } from '../config/types.js'; import { SpeedManager } from '../speed/SpeedManager.js'; import { FrameBuilder } from '../frame/FrameBuilder.js'; import { SpinResult, ReelSetEvents } from '../events/ReelEvents.js'; import { EventEmitter } from '../events/EventEmitter.js'; import { PhaseFactory } from './phases/PhaseFactory.js'; import { SpinningMode } from './modes/SpinningMode.js'; import { Disposable } from '../utils/Disposable.js'; import { CellPin } from '../pins/CellPin.js'; import { ColumnTarget } from '../frame/ColumnTarget.js'; import { Cell } from '../cascade/tumbleAlgorithm.js'; /** * MultiWays/big-symbol coordination hook injected by `ReelSet` into * `SpinController`. All callbacks are no-ops (and `isMultiWaysSlot=false`) * for non-MultiWays slots, so the standard chain is unchanged. */ export interface SpinControllerHooks { isMultiWaysSlot: boolean; symbolsData: Record; /** Read pending MultiWays shape. Returns null when no shape is pending. */ peekTargetShape(): number[] | null; /** Clear pending shape after AdjustPhase runs. */ clearTargetShape(): void; /** Reel pixel-box height for MultiWays cell-height derivation. */ multiwaysReelPixelHeight: number; symbolGapY: number; /** Reel-scoped pin lookup. Used to build AdjustPhase tween descriptors. */ getPinsOnReel(reelIndex: number): CellPin[]; /** * Migrate pins on a reel to a new visible-row count, returning the * resulting moves. Mutates the pin map directly inside ReelSet. */ migratePinsForReel(reelIndex: number, newRows: number): { pin: CellPin; fromRow: number; toRow: number; clamped: boolean; }[]; /** * Reposition + resize every pin overlay on the given reel. Called after * AdjustPhase commits a MultiWays reshape so overlays move to their new * (post-migration) row at the new cell size. */ refreshPinOverlaysForReel(reelIndex: number): void; /** * Build AdjustPhase pin-overlay tween descriptors for a reel. one per * active pin overlay. Captures pre-reshape (current) Y/size from the * overlay and computes post-reshape target. Called BEFORE the reshape * commits so the "from" state reflects what's actually on screen. */ buildPinOverlayTweens(reelIndex: number, targetSymbolHeight: number, symbolGapY: number): import('./phases/AdjustPhase.js').PinOverlayTween[]; } /** * The conductor of a spin. * * A reel set has many moving parts; the `SpinController` is the single * brain that drives them in time. On `spin()` it walks every reel through * its phase state machine (`StartPhase` → `SpinPhase` → optional * `AnticipationPhase` → `StopPhase`), applies the per-reel staggered * delays from the `SpeedProfile`, and resolves a promise when the last * reel lands (or the spin is skipped). * * It does not draw anything. drawing lives on `Reel` and `ReelSymbol`. * It does not decide outcomes. that's `setResult(grid)` coming in from * your game code. Its one job is timing. * * Every interesting moment fires on the event bus: * `spin:start`, `spin:allStarted`, `spin:stopping`, `spin:reelLanded`, * `spin:allLanded`, `spin:complete`, `skip:requested`, `skip:completed`. */ export declare class SpinController implements Disposable { private _reels; private _speedManager; private _frameBuilder; private _phaseFactory; private _events; private _tickerRef; private _spinningMode; private _defaultSpinMode; private _currentSpinMode; private _hooks; private _isSpinning; private _spinStartTime; private _resultSymbols; private _anticipationReels; /** * How the START of each anticipation reel's slow-down is spaced. See * {@link setAnticipation}. `0` (or a single tease reel) reproduces the * legacy behaviour where every anticipation reel begins slowing at once. */ private _anticipationStagger; /** * Progressive slow-down curve applied across the tease sequence, or `null` * for the flat default (every anticipation reel drops to the phase default * of 30% spin speed). See {@link setAnticipation}. Cleared per spin. */ private _anticipationSlowdown; /** * Explicit anticipation hold (ms) that OVERRIDES the active speed profile's * `anticipationDelay`. Set via `setAnticipation(reels, { duration })`. `null` * means "use the profile". A positive value also lets the tease play when the * profile's `anticipationDelay` is `0` (Turbo / SuperTurbo). Cleared per spin. */ private _anticipationDuration; /** * Reels that actually entered a tease this spin. populated when * `anticipation:reel` fires, drained in `_markLanded` to fire * `anticipation:reelEnd` only for reels that teased. Cleared per spin. */ private _teasingReels; /** * `'sequential'` anticipation chaining state: one deferred per anticipation * reel, resolved when that reel lands (in `_markLanded`). Reel at tease-order * `k` awaits the deferred of the reel at order `k-1` before starting its * tease. Rebuilt each `setAnticipation('sequential')`; cleared per spin. */ private _reelLandedResolvers; private _reelLandedPromises; private _stopDelayOverride; private _activePhases; private _landedReels; /** * Reels held for the current spin (per `SpinOptions.holdReels`). Held * reels skip START / SPIN / STOP and stay on their current symbols. * Cleared at the start of every spin. */ private _heldReels; private _wasSkipped; private _skipPending; private _isDestroyed; private _currentSpinResolve; private _currentSpinReject; /** * Set by `_abortSpin()` so the shared settle point `_finishSpin()` rejects * the spin promise (and skips the success events) instead of resolving. */ private _pendingAbortError; /** Removes the active spin's abort listener and clears its watchdog timer. */ private _spinWatchdogCleanup; /** Incremented on each new spin. If a callback sees a stale generation, it no-ops. */ private _spinGeneration; /** * Round-aware `skip()` state. Lives across `refill()` calls within a * round (one `spin()` + its cascade refills) and resets on the next * `spin()`. * * `0`. no press yet this round. * `2`. a press has slammed (and applied the round's side effect: a * speed boost in standard mode or auto-slam-refills in cascade). * Subsequent presses also slam. * * `1` is reserved (kept for forward compat in the type) but currently * unreachable. every press slams now, side effects are applied on the * first press together with the slam. */ private _skipStage; /** * Speed profile name that was active when the round-start boost fired, * captured so the next `spin()` can restore it. `null` between rounds and * during rounds where the player never pressed skip. */ private _skipPreviousSpeedName; /** * Speed profile name we boosted INTO. Kept for telemetry / debugging; * the restore decision uses `_manualSpeedSinceBoost` instead, which * correctly distinguishes "user didn't touch speed" from "user happened * to manually re-set to the boosted value" (the activeName check alone * can't tell those apart). */ private _skipBoostedToName; /** * `true` when the app called `setSpeed()` between the round-start boost * and the next `spin()`. i.e. the user made an explicit speed choice * after the boost. The next `spin()` restore path checks this flag and * SKIPS the restore so the manual choice survives, even if the manual * choice happens to be the same name we boosted into. * * Set by `notifyManualSpeedChange()` (called from `ReelSet.setSpeed`). * Cleared at the start of every `spin()` together with the boost * bookkeeping. */ private _manualSpeedSinceBoost; /** * Cascade-mode round flag. When true, the next `refill()` skips its * phase chain and slams instantly. Set when the player presses `skip()` * during a cascade round (one press = "fast-forward to end of round"). * Cleared on the next `spin()` alongside the rest of the stage state. */ private _autoSlamRefills; constructor(reels: Reel[], speedManager: SpeedManager, frameBuilder: FrameBuilder, phaseFactory: PhaseFactory, events: EventEmitter, ticker: Ticker, spinningMode?: SpinningMode, defaultSpinMode?: 'standard' | 'cascade', hooks?: SpinControllerHooks); get isSpinning(): boolean; get isDestroyed(): boolean; /** * Current `skip()` position within the active round. `0` until the * player presses the slam button, `2` after. Use to drive UI button * labels (e.g. "Skip" → "Skipped"). `1` is reserved for forward compat * and is not currently reachable. */ get skipStage(): 0 | 1 | 2; spin(options?: SpinOptions): Promise; /** * Wrap a per-reel async phase chain with an error guard. If the chain * rejects we log the error and force a slam so: * 1. the spin promise resolves with `wasSkipped: true` instead of * hanging forever waiting for the failed reel to land, * 2. every other reel is brought to a clean landed state, * 3. the next `spin()` / `refill()` starts from a coherent snapshot. * * Generation-guarded so a late rejection from a stale spin (one that * was already replaced by a fresh `spin()` call) is dropped silently. */ private _runReelTask; /** * Filter `holdReels` down to a clean Set: drop out-of-range, drop * duplicates, drop non-integer entries. Returning a normalized set * makes every internal call site safe to read without re-validating. */ private _normalizeHoldReels; setResult(symbols: string[][]): void; /** * Tumble cascade: place + drop-in for a refill (Moment B). Skips the * fall and the wait-for-result. the caller already cleared the winning * cells in user code and is now handing us the next grid directly. * * Two refill modes: * * - `'combined'` (default). survivors and new symbols animate together * in one drop-in phase. The classic Sweet Bonanza / Sugar Rush feel. * - `'gravity-then-drop'`. survivors slide down to fill holes FIRST * (gravity stage), then a global hold, then new symbols drop in from * above (drop-in stage). The Mummyland Treasures / Reactoonz feel. * gives space for anticipation visuals between the two beats. Per-reel * stop delays (`setDropOrder`) apply to the drop-in stage only; the * gravity stage runs simultaneously across all reels. * * The hold between gravity and drop-in is the **max** of three sources * (Promise.all semantics. whichever finishes LAST gates the drop-in): * * - `gravityHoldMs` (default `250`). fixed wall-clock pause via setTimeout. * - `gravityHold: Promise`. caller-supplied promise. Use when you * already have an in-flight animation/SFX/etc. and want to wait for it * by handle rather than wrapping in a callback. * - `onGravityComplete: () => Promise | void`. callback invoked * at the gravity-end boundary; its returned promise is awaited. * * `gravityHoldMs` and `gravityHold` race in parallel (Promise.all of the * two. both must finish before drop-in starts). `onGravityComplete` runs * AFTER both complete, so it can read final state of whatever they were * waiting on. * * Throws if a spin or refill is already in flight, if `.tumble(...)` was * not configured on the builder, if the grid shape doesn't match the * reel set, or if any winner cell is out of range. All validation runs * BEFORE the spinning state is taken so a thrown error leaves the engine * idle (callers can retry without re-entry errors). */ refill(opts: { winners: ReadonlyArray; grid: ColumnTarget[]; mode?: 'combined' | 'gravity-then-drop'; gravityHoldMs?: number; /** * Promise (or zero-arg factory) gating the drop-in stage. Pass a * factory function. `() => Promise`. to defer creation until * the engine actually reaches the gravity-end boundary; the side * effect of building the promise (e.g. starting a multiplier * animation) then lines up with the gravity-end beat the player sees. * Pass a bare `Promise` if you already have an in-flight * animation handle you just want the engine to wait on. */ gravityHold?: Promise | (() => Promise); onGravityComplete?: () => Promise | void; }): Promise; private _refillReel; /** * Two-stage refill: place + gravity (all reels parallel, no stop delay), * global hold, then drop-in (all reels parallel, with stop delays). * Survivors slide first; new symbols enter after the hold. See `refill` * for the player-facing description. */ private _refillTwoStage; private _refillReelDropInOnly; /** * Mark reels to tease, and shape how they slow down. * * The second argument is either a bare `stagger` value or a full * `{ stagger, slowdown }` options object. * * `stagger` controls when each anticipation reel BEGINS slowing (offsets * are by tease-order. position within `reelIndices`. not raw reel index): * - `0` (default): all teases start together (legacy parallel behaviour). * - `number`: reel at tease-order `k` starts after `k * stagger` ms. * - `number[]`: explicit per-tease-order offset in ms (`stagger[k]`). * - `'sequential'`: each reel waits until the previous anticipation reel * has fully landed before it starts. maximal one-at-a-time tension. * * `slowdown` makes the deceleration progressive across the sequence: each * successive reel slows to a lower speed (`from` → `to`) and/or holds longer * (`holdFrom` → `holdTo`). Omit it for the flat 30%-and-hold default. * * `duration` overrides the active speed profile's `anticipationDelay` (ms). * Pass a positive value to make the tease play even in Turbo / SuperTurbo, * whose profiles have `anticipationDelay: 0` and would otherwise skip it. */ setAnticipation(reelIndices: number[], options?: AnticipationStagger | AnticipationOptions): void; /** * Override the per-reel stop delay (in ms). Pass one value per reel. * When set, these replace the staggered `reelIndex * speed.stopDelay` * pattern. Pass `null` to CLEAR the override and restore that default * (distinct from passing all-zeros, which lands every reel at once). */ setStopDelays(delays: number[] | null): void; /** * Slam-stop safe before `setResult()` arrives. Queues until a result is * set, then slams. Bypasses the two-stage `skip()` machine. this API is * for callers with explicit slam intent (e.g. UIs that wire the queued * slam separately from a stage-aware button). */ requestSkip(): void; /** * Round-aware skip. the button-press entry point used by the universal * "spin/skip" button pattern across recipes. First press in a round * slams the current drop AND applies the round's speed effect as a * side-effect: * * - Standard mode: boost the active speed profile to the fastest * registered one and emit `skip:boosted`. The speed change takes * effect on subsequent spins (mid-spin speed switching is not * supported by phases). Restored to the player's original profile * on the next `spin()`. * - Cascade/tumble mode: flag the round so every subsequent * `refill()` auto-slams instantly (no animation). One press ends * a multi-drop cascade round. * * Subsequent presses in the same round slam each current drop. * * Throws if called before `setResult()` arrives (no result to slam onto *. slamming now would land the reels on the random spin-buffer state). * Use {@link requestSkip} for the deferred slam pattern: it queues the * slam and fires it the moment `setResult()` arrives, so the reels land * on the intended grid. (Refill paths set the result at entry, so this * guard fires only during the pre-`setResult` window of `spin()`.) * * Callers who want only the slam without the boost or auto-slam side * effects (tests, anti-cheat, programmatic automation) should use * `slamStop()` instead. */ skip(): void; /** * Hard slam-stop. Always lands every un-landed reel immediately, regardless * of stage. Sets `skipStage` to 2 so future `skip()` presses in this round * also slam (the boost ship has sailed). */ slamStop(): void; /** * The slam path itself: force-complete active phases, place results (or * snap to current symbols when no result is set), mark every un-landed * reel as landed. Shared by `skip()` (stage 1+), `requestSkip()`'s * deferred path, `slamStop()`, and the per-reel error-recovery path * inside `_runReelTask`. * * Idempotent: a second call once the spin has finished is a no-op. Lets * cascading rejection handlers each safely invoke `_slam` without * triple-emitting `skip:requested`. */ private _slam; /** * Called by `ReelSet.setSpeed()` after the speed manager applies a * user-driven profile change. Sets the flag the next `spin()` checks * to decide whether to undo a prior `skip()` boost. Internal-only. * not part of the SpinController public API. * * Idempotent if no boost is pending (the flag is consulted only when * `_skipPreviousSpeedName !== null`). */ notifyManualSpeedChange(): void; /** * Pick the registered speed profile with the highest `spinSpeed` (pixels * per frame at full motion). Returns `null` if only one profile exists, * since a "boost to yourself" is meaningless. */ private _findFastestSpeedName; destroy(): void; /** * Compute the target cell height for a reel given a target row count. * MultiWays slots derive cell height from the fixed `multiwaysReelPixelHeight`; * non-MultiWays slots return the reel's current `symbolHeight` unchanged. */ private _targetCellHeightFor; /** * Commit a reshape on one reel: emit `adjust:start`, call `reel.reshape()`, * refresh pin overlays, emit `adjust:complete`. Returns whether work was * actually done. * * **The single source of truth** for reshape orchestration. both the * normal AdjustPhase path AND the skip path call this. Avoids the * "two parallel implementations" bug magnet that previously had each * path duplicating the same compute-target-height + reshape + refresh + * emit-events logic. * * Pin migration already happened at `setShape()` time, so this method * only handles geometry + overlays. */ private _applyReshape; private _startReel; /** * MultiWays AdjustPhase orchestration: pull the pending shape, migrate * pins to their new rows, build pin-overlay tween descriptors, run the * phase. Emits `adjust:start` on entry and `adjust:complete` on exit. * * **Skips entirely** when there's no shape change AND no pin overlay on * this reel. no phase instance is constructed and no `adjust:*` events * fire. A spin where most reels have no work shouldn't pay for a phase * boundary or spam the event bus. */ private _runAdjustForReel; /** * Wait for a reel's anticipation-start offset before it begins slowing. * Returns `false` if the spin generation changed during the wait (a slam or * a fresh spin superseded this task) so the caller bails cleanly. * * Offsets are keyed by tease-order (position within `_anticipationReels`), * not raw reel index, so teasing `[2,3,4]` spaces them `[0, S, 2S]` * regardless of which physical reels they are. */ private _awaitAnticipationOffset; /** * Build the per-reel AnticipationPhase config from the active `slowdown` * curve and `duration` override. Interpolates `from`→`to` (speed) and * `holdFrom`→`holdTo` (duration) across tease-order so each successive reel * decelerates deeper / holds longer. Base hold is the `duration` override * when set, else the profile's `anticipationDelay`. Returns an empty config * (phase defaults: 30% speed, `anticipationDelay` hold) when neither a * slowdown nor a duration override is configured. */ private _anticipationConfigFor; private _stopDelayFor; private _cachedFrames; private _frameFor; private _tryBeginStopSequence; /** * Big symbols cross-reel coordinator. Walks the result grid, locates big * symbols (those with `SymbolData.size.w * size.h > 1`), validates that * the block fits within reel bounds, and paints OCCUPIED sentinels into * the non-anchor cells so per-reel FrameBuilder leaves them alone. * * Pure: returns a new grid; does not mutate the input. Zero-overhead for * slots with no big symbols (the loop runs but never matches metadata). * * The clone preserves negative-index string properties (`arr[-1]`, ...) * that carry buffer-above targets in the legacy form, so downstream * consumers (FrameBuilder, placeSymbols) still see them on `decorated[col]`. */ private _coordinateBigSymbols; private _markLanded; /** * Wire up the optional abort signal and timeout watchdog for this spin. * Both routes call `_abortSpin`, which force-stops the reels and rejects the * spin promise. Cleared by `_finishSpin` / `_abortSpin` when the spin settles. */ private _armSpinWatchdog; private _clearSpinWatchdog; private _abortError; /** * Force-stop an in-flight spin and reject its promise. Reuses the proven * `_slam()` recovery (kills phase tweens, snaps reels to a clean grid when no * result is set), then `_finishSpin()` rejects instead of resolving because * `_pendingAbortError` is set. */ private _abortSpin; private _finishSpin; private _onTick; } //# sourceMappingURL=SpinController.d.ts.map