import { gsap } from 'gsap'; import { DisplayObject } from '@pixi/display'; import { DynamicGridCollection, DynamicReel, SoundController, Reel, SlotMachine } from "."; import { AnticipationAnimationManager } from "./anticipation/AnticipationAnimationManager"; import { ColumnState, GridProps, ReelTweenAnimation, SlotMachineProps, SlotMachineState, SymbolData } from "./types"; export class SpinController { public state: SlotMachineState = SlotMachineState.Ready; private _spinningDuration = 1000; private _startingSpeed = 50; private _stoppingDelay = 150; private _minSpinTime = 2000; private _normalMinSpinTime = 2000; private _turboMinSpinTime = 800; private _jerkDistance = 50; private _readyToStop = false; private _stopQueued = false; private _forceStopRequested = false; private _stoppingTimer: NodeJS.Timer | undefined = undefined; private _columnCount: number; private _onCompleted: () => void; private _tweenAnimations: ReelTweenAnimation[] = []; private _soundController: SoundController; constructor( private _slotMachine: SlotMachine, private _dynamicGridCollection: DynamicGridCollection, private _gridProps: GridProps, private _slotMachineProps: SlotMachineProps, private _anticipationAnimationManager?: AnticipationAnimationManager, ) { this._columnCount = _gridProps.reelProps.length; this._soundController = _slotMachine.soundController; this.setProps(_slotMachineProps); } private setProps(props: SlotMachineProps) { this._spinningDuration = this._spinningDuration ?? props.spinningDuration; this._startingSpeed = this._startingSpeed ?? props.startingSpeed; this._stoppingDelay = this._stoppingDelay ?? props.stoppingDelay; this._normalMinSpinTime = this._normalMinSpinTime ?? props.normalMinSpinTime; this._turboMinSpinTime = this._turboMinSpinTime ?? props.turboMinSpinTime; this._jerkDistance = this._jerkDistance ?? props.jerkDistance; } public toggleTurboMode(turbo = true) { this._minSpinTime = turbo ? this._turboMinSpinTime : this._normalMinSpinTime; } // Start a timer to force wheel to spin for a minimum time public startStoppingTimer(time = this._minSpinTime) { this._stoppingTimer = setTimeout(() => { this._readyToStop = true; this.handleColumnStopping(); this._stoppingTimer = undefined; }, time) } public normalStop(onCompleted: () => void) { this._onCompleted = onCompleted; this._stopQueued = true; this.handleColumnStopping(); } // Force stop wheel while spinning. public forceStop(onCompleted: () => void) { this._onCompleted = onCompleted; if (this.state === SlotMachineState.Spinning || this.state === SlotMachineState.Starting) { if (this._stoppingTimer !== undefined) { clearTimeout(this._stoppingTimer); this._stoppingTimer = undefined; } this._stopQueued = true; this._readyToStop = true; this._forceStopRequested = true; this.handleColumnStopping(); } } // Check if we can stop the spinning. If so, start stopping process. private handleColumnStopping() { if (this._stopQueued && this._readyToStop && this.state === SlotMachineState.Spinning) { this.state = SlotMachineState.Stopping; this._stopQueued = false; this._readyToStop = false; this.startColumnStopping(this._slotMachine.finalSymbols, 0, this._forceStopRequested); } } /** * Prepare and start columns one-by-one, starting with given index. * After a column is prepared, start the {@link spinColumn spinning} recursion. * @param columnIndex starting index */ public startSpinningColumns(columnIndex = 0): void { while (columnIndex < this._columnCount) { setTimeout((index) => { this._dynamicGridCollection.columnsStates[ index ] = ColumnState.Spinning; if (index === 0) { this._soundController.playSpinningSfx(); } else if (index === this._columnCount - 1) { this.state = SlotMachineState.Spinning; } this._dynamicGridCollection.setColumnVisibility(index, true); this._slotMachine.staticGrid.setReelVisibility(index, false); this.spinColumn(index, this._dynamicGridCollection.gridA.reels[ index ], this._dynamicGridCollection.gridB.reels[ index ], true); }, this._startingSpeed + this._startingSpeed * columnIndex, columnIndex); columnIndex++; } } /** * Search for the first symbol in the column that is out of the frame. * @param columnIndex index of the column to search on * @returns index number and a boolean which is true if index is on upperGhostReel */ private getNextSymbolIndex(columnIndex: number): { index: number, onUpperReel: boolean } { const rowCount = this._dynamicGridCollection.gridA.reels[ columnIndex ].props.symbolCount; for (let i = 0; i < rowCount; i++) { let isVisible = this._dynamicGridCollection.gridA.reels[ columnIndex ].isSymbolVisible(i, this._slotMachine.staticGrid.reels[ columnIndex ]); if (!isVisible) { if (i > 0) { if (this._dynamicGridCollection.gridA.reels[ columnIndex ].isSymbolVisible(i - 1, this._slotMachine.staticGrid.reels[ columnIndex ])) { return { index: i, onUpperReel: false }; } } else if (i === 0) { if (this._dynamicGridCollection.gridB.reels[ columnIndex ].isSymbolVisible(rowCount - 1, this._slotMachine.staticGrid.reels[ columnIndex ])) { return { index: i, onUpperReel: false }; } } } } for (let i = 0; i < rowCount; i++) { let isVisible = this._dynamicGridCollection.gridB.reels[ columnIndex ].isSymbolVisible(i, this._slotMachine.staticGrid.reels[ columnIndex ]); if (!isVisible) { if (i > 0) { if (this._dynamicGridCollection.gridB.reels[ columnIndex ].isSymbolVisible(i - 1, this._slotMachine.staticGrid.reels[ columnIndex ])) { return { index: i, onUpperReel: true }; } } else if (i === 0) { if (this._dynamicGridCollection.gridA.reels[ columnIndex ].isSymbolVisible(rowCount - 1, this._slotMachine.staticGrid.reels[ columnIndex ])) { return { index: i, onUpperReel: true }; } } } } return { index: 0, onUpperReel: false }; } private calcSpeed(originalPosY: number, targetPosY: number, originalDuration: number) { return (Math.abs(originalPosY - targetPosY)) / originalDuration; } private calcDuration(currentPosY: number, targetPosY: number, speed: number) { return Math.floor((Math.abs(currentPosY - targetPosY)) / speed); } // Compare and make sure the symbol index we are using is higher in abs position than the previous column's symbol index. private compareSymbolPosition(upperReel: Reel, centerReel: Reel, onUpperReel: boolean, nextSymbolIndex: number, previousColumnSymbolPositionObj: { y: number }) { let currentColumnSymbolPositionY = onUpperReel ? upperReel.symbols[ nextSymbolIndex ].getGlobalPosition().y : centerReel.symbols[ nextSymbolIndex ].getGlobalPosition().y; if (currentColumnSymbolPositionY >= previousColumnSymbolPositionObj.y) { nextSymbolIndex++; } previousColumnSymbolPositionObj.y = currentColumnSymbolPositionY; return nextSymbolIndex; } // Check if symbol index is on current reel or we moved to next reel private correctSymbolColumnAndIndex(nextSymbolIndex: number, onUpperReel: boolean, rowCount: number) { if (nextSymbolIndex >= rowCount) { nextSymbolIndex = (nextSymbolIndex % (rowCount - 1) - 1); onUpperReel = onUpperReel ? false : true; } return { nextSymbolIndex, onUpperReel }; } // Set the symbols of given index...index+2 to the final symbols. private setFinalSymbols(columnIndex: number, nextSymbolIndex: number, onUpperReel: boolean, finalSymbols: SymbolData[][]) { let currentReel = onUpperReel ? this._dynamicGridCollection.gridB.reels[ columnIndex ] : this._dynamicGridCollection.gridA.reels[ columnIndex ]; let otherReel = onUpperReel ? this._dynamicGridCollection.gridA.reels[ columnIndex ] : this._dynamicGridCollection.gridB.reels[ columnIndex ]; let _firstSymbolIndex = nextSymbolIndex; const rowCount = currentReel.props.symbolCount; const staticGridRowCount = this._slotMachine.staticGrid.reels[ columnIndex ].props.symbolCount; /** * Check if other reel is higher than current reel. * If so, start placing symbol while check when to move to other reel. * If not, decrease nextSymbolIndex until we can fit all 3 symbols on the same reel. Then place symbols. * */ if (otherReel.getGlobalPosition().y < currentReel.getGlobalPosition().y) { for (let i = 0; i < staticGridRowCount; i++, nextSymbolIndex++) { // Place symbols on other reel if needed const correctedSymbolData = this.correctSymbolColumnAndIndex(nextSymbolIndex, onUpperReel, rowCount); nextSymbolIndex = correctedSymbolData.nextSymbolIndex; // If onUpperReel not equals corrected onUpperReel, switch _currentReel and _otherReel if (onUpperReel !== correctedSymbolData.onUpperReel) { const tempReel = currentReel; currentReel = otherReel; otherReel = tempReel; } currentReel.symbols[ nextSymbolIndex ].change(finalSymbols[ columnIndex ][ i ]); console.log("set final symbol", nextSymbolIndex, finalSymbols[ columnIndex ][ i ], onUpperReel); // console.log("index", columnIndex, "on upper column", onUpperReel, "symbol index", nextSymbolIndex, "symbol", this._symbols[ columnIndex ][ i ]); } } else { if (nextSymbolIndex + (staticGridRowCount - 1) >= rowCount) { nextSymbolIndex = nextSymbolIndex - ((nextSymbolIndex + (staticGridRowCount - 1)) % (rowCount - 1)); _firstSymbolIndex = nextSymbolIndex; } for (let i = 0; i < staticGridRowCount; i++, nextSymbolIndex++) { currentReel.symbols[ nextSymbolIndex ].change(finalSymbols[ columnIndex ][ i ]); console.log("set final symbol", nextSymbolIndex, finalSymbols[ columnIndex ][ i ], onUpperReel); } } return _firstSymbolIndex; } private stoppingTweens(index: number, centerReel: Reel, upperReel: Reel, distance: number, duration: number, speed: number, forceStopActive: boolean, delayedStop: boolean) { const easingDown = "sine.out"; const easingUp = "sine.inOut"; const centerReelFinalPosY = centerReel.position.y + distance; const upperReelFinalPosY = upperReel.position.y + distance; const playSound = !delayedStop && !forceStopActive; const backUpDuration = Math.floor(this._spinningDuration / 4) / 1000; duration /= 1000; const timeline1 = gsap.timeline({}) .to(centerReel, { duration, y: centerReelFinalPosY + this._jerkDistance, ease: easingDown }) .call(() => {playSound && this._soundController.playStopSound(index)}) .to(centerReel, { duration: backUpDuration, y: centerReelFinalPosY, ease: easingUp }); const timeline2 = gsap.timeline({}) .to(upperReel, { duration, y: upperReelFinalPosY + this._jerkDistance, ease: easingDown }) .to(upperReel, { duration: backUpDuration, y: upperReelFinalPosY, ease: easingUp }); const finalTimeline = gsap.timeline({}); finalTimeline.add(timeline1, 0).add(timeline2, 0); return finalTimeline; } private calculateStoppingDelays() { const symbolCounter: number[] = Array(Object.keys(this._gridProps.symbolsConfig).length).fill(0); let delayMultiplier = 0; let delays: number[] = []; delays[ 0 ] = 0; if (this._slotMachineProps.anticipationConfig) { for (let i = 1; i < this._gridProps.reelProps.length; i++) { delays[ i ] = 0; this._slotMachine.finalSymbols[ i - 1 ].forEach((sym) => { for (let symID in this._slotMachineProps.anticipationConfig?.triggeringAmounts) { if (parseInt(symID) === sym.id) { symbolCounter[ sym.id ]++; } } }) // If we find the specified amount of the given symbol, we set a delay symbolCounter.forEach((symCount, symID) => { if (symCount >= (this._slotMachineProps.anticipationConfig?.triggeringAmounts[ symID ] ?? 0)) { delayMultiplier++; delays[ i ] = (this._slotMachineProps.anticipationConfig?.delayTime ?? 2600) * delayMultiplier; } }) } } return delays; } // Start stopping the columns one-by-one private startColumnStopping(finalSymbols: SymbolData[][], index = 0, forceStopActive = false): void { let { index: nextSymbol, onUpperReel: nextSymbolOnUpperReel } = this.getNextSymbolIndex(index); const rowCount = this._dynamicGridCollection.gridA.reels[ index ].props.symbolCount; const rowHeight = this._dynamicGridCollection.gridA.reels[ index ].distanceBetweenSymbols; let previousColumnSymbolPositionObj = { y: nextSymbolOnUpperReel ? this._dynamicGridCollection.gridB.reels[ 0 ].symbols[ nextSymbol ].getGlobalPosition().y : this._dynamicGridCollection.gridA.reels[ 0 ].symbols[ nextSymbol ].getGlobalPosition().y }; const originalSpeed = this.calcSpeed(0, (rowCount * rowHeight), this._spinningDuration / 2.5); let newDuration = 0; const stopColumn = (index: number, stoppingDelays: number[]) => { const centerReel = this._dynamicGridCollection.gridA.reels[ index ]; const upperReel = this._dynamicGridCollection.gridB.reels[ index ]; const rowCount = this._dynamicGridCollection.gridA.reels[ index ].props.symbolCount; // Pause current column's spinning tween timeline this._tweenAnimations[ index ].timeline?.pause(); let onUpperReel = nextSymbolOnUpperReel; let nextSymbolIndex = nextSymbol; let delayedStop = false; if (stoppingDelays.length > 0 && stoppingDelays[ index ] > 0) { let { index: _nextSymbolIndex, onUpperReel: _nextSymbolOnUpperReel } = this.getNextSymbolIndex(index); nextSymbolIndex = _nextSymbolIndex; onUpperReel = _nextSymbolOnUpperReel; delayedStop = true; } else { if (index > 0) nextSymbolIndex = this.compareSymbolPosition(upperReel, centerReel, onUpperReel, nextSymbolIndex, previousColumnSymbolPositionObj); this._dynamicGridCollection.columnsStates[ index ] = ColumnState.Stopping; const correctedSymbolData = this.correctSymbolColumnAndIndex(nextSymbolIndex, onUpperReel, rowCount); nextSymbolIndex = correctedSymbolData.nextSymbolIndex; onUpperReel = correctedSymbolData.onUpperReel; } // Set 3 symbols to the final symbols, starting from "nextSymbolIndex" nextSymbolIndex = this.setFinalSymbols(index, nextSymbolIndex, onUpperReel, finalSymbols); let symbolPositionY = onUpperReel ? upperReel.symbols[ nextSymbolIndex ].getGlobalPosition() : centerReel.symbols[ nextSymbolIndex ].getGlobalPosition(); const symbolLandingPosition = this._slotMachine.staticGrid.reels[ index ].symbols[ 0 ].getGlobalPosition(); console.log(index, "symbolPositionY", nextSymbolIndex, symbolPositionY, symbolLandingPosition); /** * Calculate the time it takes for the symbol to land on the reel in the first column. * If we are not in force stop or not using delay, increase the duration gradually. * Otherwise, use the same duration everywhere. */ if (index === 0) { newDuration = this.calcDuration(symbolPositionY.y, symbolLandingPosition.y, originalSpeed); } else if (!forceStopActive && !delayedStop) { newDuration += this._stoppingDelay; } // Calculate the difference between the selected symbol position and the position where it is supposed to land relative to the slot machine container const moveColumnByPosY = Math.abs(this._slotMachine.toLocal(symbolPositionY).y - this._slotMachine.toLocal(symbolLandingPosition).y); //console.log('index: %d, nextSymbolIndex: %d, symbolPositionY: %f, newDuration: %f, originalSpeed: %f, spinningDuration: %f', index, nextSymbolIndex, symbolPositionY, newDuration, originalSpeed, this._spinningDuration); const columnTimeline = this.stoppingTweens(index, centerReel, upperReel, moveColumnByPosY, newDuration, originalSpeed, forceStopActive, delayedStop); columnTimeline.call(this.onColumnStoppingComplete.bind(this), [ index, forceStopActive, stoppingDelays ]) } const stoppingDelays = this.calculateStoppingDelays(); while (index < this._gridProps.reelProps.length) { if (!forceStopActive) { setTimeout((_index: number) => { stopColumn(_index, stoppingDelays); }, stoppingDelays[ index ], index); } else { stopColumn(index, []); } index++; } } // Called when a column stops. private onColumnStoppingComplete(columnIndex: number, forceStopActive: boolean, columnStoppingDelays: number[]) { if (forceStopActive && columnIndex === this._columnCount - 1) { this._soundController.playStopSoundOnForceStop(); } this._dynamicGridCollection.setColumnVisibility(columnIndex, false); this._slotMachine.staticGrid.setReelVisibility(columnIndex, true); this._dynamicGridCollection.columnsStates[ columnIndex ] = ColumnState.Ready; // If next column's stopping delay is bigger then 0, play anticipation sound if (columnStoppingDelays[ columnIndex + 1 ] > 0 && !forceStopActive) { this._soundController.playAnticipationSound(); this._anticipationAnimationManager?.playAnimation(columnIndex + 1); } // We this column was stopped with delay, we need to remove anticipation animation if (columnStoppingDelays[ columnIndex ] > 0) { this._anticipationAnimationManager?.stopAnimation(columnIndex); } let allColumnsReady = true; for (let i = 0; i < this._gridProps.reelProps.length; i++) { if (this._dynamicGridCollection.columnsStates[ i ] !== ColumnState.Ready) { allColumnsReady = false; } } if (allColumnsReady) { this.onAllStoppingComplete(); } } // Called when the all the columns stop. private onAllStoppingComplete() { //console.log("all stopped"); this._soundController.stopSpinningSfx(); this._tweenAnimations = []; this.state = SlotMachineState.Ready; this._forceStopRequested = false; this._dynamicGridCollection.resetColumns(); this._onCompleted() } /** * Wrapper function for tween. * @param obj the object to tween * @param posY the position to move to * @param duration the duration of the tween in ms * @param relativeCoords if true, posY is relative to the object's current position * @param ease the easing function to use * @param onCompleteFn the function to call when the tween is complete * @returns the tween object, can be used to stop the tween */ private moveToY(obj: DisplayObject, posY: number, duration: number, relativeCoords = false, ease?: gsap.EaseFunction | string, onComplete?: gsap.Callback): gsap.core.Tween { duration /= 1000; // convert to seconds for gsap const tween = gsap.to(obj, { y: relativeCoords ? obj.y + posY : posY, duration, ease, onComplete }); return tween; } /** * Recursively keep spinning column until they are stopped/paused. * @see {@link startColumnStopping} */ private spinColumn(columnIndex: number, centerColumn: DynamicReel, upperColumn: DynamicReel, starting = false): void { const easing = starting ? "back.in(1)" : "none"; const rowCount = centerColumn.props.symbolCount; const rowHeight = centerColumn.distanceBetweenSymbols; const anim1 = this.moveToY(centerColumn, (rowCount * rowHeight), starting ? this._spinningDuration : this._spinningDuration / 2.5, true, easing, () => { centerColumn.y += (rowCount * rowHeight) * -2; }); const anim2 = this.moveToY(upperColumn, (rowCount * rowHeight), starting ? this._spinningDuration : this._spinningDuration / 2.5, true, easing); const timeline = gsap.timeline({ onComplete: () => { if (this._dynamicGridCollection.columnsStates[ columnIndex ] !== ColumnState.Stopping) { // Swap reels' Z index // TODO this._maskedContainer.swapChildren(centerColumn.container, upperColumn.container); this.spinColumn(columnIndex, upperColumn, centerColumn); } } }) .add(anim1, 0) .add(anim2, 0); const animationTimeline: ReelTweenAnimation = { timeline: timeline, columnIndex: columnIndex, finished: false }; this._tweenAnimations[ columnIndex ] = animationTimeline; } }