btroncone / learn-rxjs

Clear examples, explanations, and resources for RxJS

Home Page:https://www.learnrxjs.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Alphabet Invasion Problems

levanroi opened this issue · comments

  1. Letter z would never be generated.
    1.1 This is easily fixable by adding +1 inside multiplication with Math.random() ×( ... + 1)
  2. If the same letter appears multiple times in sequence, user can delete them all by a single keystroke
    2.1 This can be simulated by making randomLetter return one character: randomLetter = () => 'a'
    2.2 This is not easy to fix, as that would require major refactoring. The problem comes from using combineLatest in the main logic. combineLatest cannot determine, which stream triggered the new emission - new key pressed or new letter arriving.

Here is a quick-and-dirty solution that should fix the problems (Also on StackBlitz):

import { asyncScheduler, BehaviorSubject, defer, EMPTY, fromEvent, iif, merge, Observable, of, timer } from "rxjs"
import { catchError, filter, map, observeOn, repeatWhen, retryWhen, scan, switchMap, switchMapTo, takeWhile, tap } from "rxjs/operators"

const DEFAULTS = {
    boardWidth: 30,
    boardHeight: 30,
    levelHits: 10,
    maxLevels: 8,
    newLetterProbability: 0.5,
    initialDelay: 400,
    boardId: 'AlphabetInvasionBoard',
    levelAccelerationFactor: 0.8,
    delayBetweenLevels: 2000,
}

type CSSStyleName = Exclude<keyof CSSStyleDeclaration, 'length' | 'parentRule'> & string
type CSSStyle = Partial<Record<CSSStyleName, string>>

/** Letter is a character with position information */
interface Letter {
    char: string
    offset: number // Horizontal offset from left side of the board
    height: number // Height from bottom of the board
    upper?: boolean // Whether the letter is uppercase
}

interface LetterSequence {
    level: number
    letters: Letter[]
    count: number // Number of letters hit - typed correctly
}

interface LetterSequenceWithUpdate extends LetterSequence {
    update: boolean // Does this sequence contains any new information or not
}


export function makeGame({
    boardWidth = DEFAULTS.boardWidth,
    boardHeight = DEFAULTS.boardHeight,
    levelHits = DEFAULTS.levelHits,
    newLetterProbability = DEFAULTS.newLetterProbability,
    initialDelay = DEFAULTS.initialDelay,
    boardId = DEFAULTS.boardId,
    maxLevels = DEFAULTS.maxLevels,
    levelAccelerationFactor = DEFAULTS.levelAccelerationFactor,
    delayBetweenLevels = DEFAULTS.delayBetweenLevels,
} = {}) {
    /** Pseudo-random integer from a specific range */
    function random(from: number, to: number) {
        return from + Math.floor(Math.random()*(to - from + 1))
    }

    /** Random character - without position information */
    function randomChar() {
        return Math.random() < .3 ? {
            char: String.fromCharCode(random('A'.charCodeAt(0), 'Z'.charCodeAt(0))),
            upper: true
        } : {
            char: String.fromCharCode(random('a'.charCodeAt(0), 'z'.charCodeAt(0))),
            upper: false
        }
    }

    /** Random letter - includes position information */
    function randomLetter(): Letter {
        const char = randomChar()
        return {
            char: char.char,
            upper: char.upper,
            offset: random(0, boardWidth - 1),
            height: boardHeight
        }
    }

    /** Move a single letter down by a single row */
    function moveLetter(letter: Letter): Letter {
        return {
            ...letter,
            height: letter.height - 1
        }
    }

    /** Emits keystrokes */
    const keydown$ = fromEvent(document, 'keydown') as unknown as Observable<KeyboardEvent>
    const keystrokes$ = keydown$.pipe(map(e => e.key))


    function lettersRemaining(gap: number, level: number) {
        return merge(
            keystrokes$,
            timer(delayBetweenLevels, gap)
        ).pipe(
            scan((sequence, keyOrTick) => {
                if ( typeof keyOrTick === 'string' ) {
                    const found = sequence.letters[0]?.char === keyOrTick
                    return {
                        level,
                        letters: sequence.letters.slice(+found),
                        count: sequence.count + (+found),
                        update: found
                    }
                } else {
                    const needMoreLetters = sequence.count + sequence.letters.length < levelHits
                    const letters = sequence.letters.map(moveLetter).concat(Math.random() < newLetterProbability && needMoreLetters ? [randomLetter()] : [])
                    return {
                        level,
                        letters,
                        count: sequence.count,
                        update: true
                    }
                }
            }, {letters: [], count: 0, level: 0, update: false} as LetterSequenceWithUpdate),
            filter(state => state.update), // Filter out sequences without any new information to avoid unnecessary redraw
            map<LetterSequenceWithUpdate, LetterSequence>(({level, letters, count}) => ({level, letters, count})), // Get rid of `update` - no longer needed
            takeWhile(state => ! state.letters.length || state.letters[0].height >= 0, false)
        )
    }

    function getBoard() {
        function makeNewBoard() {
            const board = document.createElement('div')
            board.setAttribute('id', boardId)
            const boardStyle: CSSStyle = {
                border: '2px solid grey',
                margin: '16px',
                padding: '16px',
                whiteSpace: 'pre',
                fontFamily: 'monospace',
                overflow: 'hidden',
                display: 'inline-block',
                fontSize: '24px',
            }
            const boardStyleDOM = board.style as unknown as CSSStyle
            Object.entries(boardStyle).forEach(
                ([styleName, styleValue]) => boardStyleDOM[styleName as CSSStyleName] = styleValue
            )
            document.body.appendChild(board)
            return board
        }
        return document.getElementById(boardId) ?? makeNewBoard()
    }


    function letterHtml(letter: Letter) {
        if ( letter.upper) {
            return `<span style="color:red;font-weight:700">${letter.char}</span>`
        }
        return letter.char
    }

    function drawBoard(board: HTMLElement) {
        return function(letterset: Letter[], boardWidth: number, gameProgress: number, levelPprogress: number) {
            const gameProgressString = 'GAME', levelProgressString = 'Level'
            const toGoPrefix = '<span style="opacity:.5;color:#ccc">', toGoSuffix = '</span>'

            const gameDone = gameProgressString.repeat(boardWidth).slice(0, boardWidth * gameProgress)
            const gameToGo = gameProgressString.repeat(boardWidth).slice(0, boardWidth - gameDone.length)
            const gameProg = gameDone + toGoPrefix + gameToGo + toGoSuffix + '\n'

            const levelDone = levelProgressString.repeat(boardWidth).slice(0, boardWidth * levelPprogress)
            const levelToGo = levelProgressString.repeat(boardWidth).slice(0, boardWidth - levelDone.length)
            const levelProg = levelDone + toGoPrefix + levelToGo + toGoSuffix + '\n'

            let html = gameProg + levelProg
            const letters: Letter[] = [{char: ' ', offset: boardWidth, height: 0}, ...letterset]
            for(let height = boardHeight, l = letters.length - 1; l >= 0; --l, --height) {
                while ( height > letters[l].height ) {
                    html += '\n';
                    --height;
                }
                const letter = letters[l]
                if (l) html += ' '.repeat(letter.offset) + letterHtml(letter) + '\n'
            }
            board.innerHTML = html
        }
    }

    function promptUser(question: string) {
        return function(notifier: Observable<unknown>) {
            return notifier.pipe(
                observeOn(asyncScheduler),
                switchMapTo(iif(() => confirm(question), of(true), EMPTY))
            )
        }
    }

    function levelFlashScreen(text: string): Letter[] {
        return [
            {
                char: text,
                height: Math.ceil(boardHeight / 2),
                offset: Math.floor((boardWidth - text.length) / 2),
            }
        ]
    }

    return defer(() => {
        const board = getBoard()
        const updateBoard = drawBoard(board)
        /** Time gap between each tick. One tick means letters drop by a single row */
        const gap$ = new BehaviorSubject(initialDelay)
        return gap$.pipe(
            switchMap((gap, level) => defer(() => {updateBoard(levelFlashScreen(`Level ${level}`), boardWidth, Math.max(0, level-1)/maxLevels, Number(level > 0)); return lettersRemaining(gap, level)})),
            tap(state => {
                if ( state.count >= levelHits && state.level < maxLevels - 1 ) gap$.next(levelAccelerationFactor * gap$.getValue())
                if ( state.letters[0]?.height <= 0 ) throw new Error('Player lost')
            }),
            takeWhile(state => state.level < maxLevels - 1 || state.count < levelHits),
            tap({
                next: state => state.count < levelHits ? updateBoard(state.letters, boardWidth, state.level/maxLevels, state.count/levelHits) : null,
                complete: () => updateBoard(levelFlashScreen('Game Over'), boardWidth, 1, 1)
            }),
        )
    }).pipe(
        repeatWhen( promptUser('You win. Play again?') ),
        retryWhen( promptUser('You loose. Play again?') ),
        catchError(() => EMPTY) // Ignore error
    )
}

makeGame().subscribe()