Alphabet Invasion Problems
levanroi opened this issue · comments
Levan Roinishvili commented
- Letter
z
would never be generated.
1.1 This is easily fixable by adding+1
inside multiplication withMath.random() ×( ... + 1)
- 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 makingrandomLetter
return one character:randomLetter = () => 'a'
2.2 This is not easy to fix, as that would require major refactoring. The problem comes from usingcombineLatest
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()