Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alphabet Invasion Problems #276

Open
levanroi opened this issue Dec 29, 2021 · 0 comments
Open

Alphabet Invasion Problems #276

levanroi opened this issue Dec 29, 2021 · 0 comments

Comments

@levanroi
Copy link

levanroi commented Dec 29, 2021

  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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant