/* * This module handles displaying the UI. The most important one is the main * area which contains the text to be typed in. Progress is displayed on the * kana part of the area, while errors are shown via the romaji section. The * kanji is simply just for reading. */ /// /// /// namespace display { import InputState = kana.KanaInputState; import TransitionResult = state.TransitionResult; class SingleKanaDisplayComponent { element: HTMLElement; finished: boolean; constructor(kana: string) { this.element = document.createElement('span'); this.element.classList.add('kana'); this.element.textContent = kana; this.element.setAttribute('data-text', kana); this.finished = false; } setPartial() { if (!this.finished) { this.element.classList.add('half'); } } setFull() { this.finished = true; this.element.classList.remove('half'); this.element.classList.add('full'); } } class KanaMachineController { state: state.StateMachine; children: SingleKanaDisplayComponent[]; current: number; get elements() { return this.children.map(kanaComponent => kanaComponent.element); } constructor(kana: string, state: state.StateMachine) { this.state = state; this.current = 0; this.state.addObserver(this.observer); this.children = kana.split('').map(c => new SingleKanaDisplayComponent(c)); } observer: state.Observer = (result, boundary) => { if (boundary) { this.children[this.current].setFull(); this.current += 1; } else if (result != TransitionResult.FAILED) { this.children[this.current].setPartial(); } } destroy(): void { this.state.removeObserver(this.observer); } } export class KanaDisplayController { children: KanaMachineController[]; constructor(readonly element: HTMLElement) { this.children = []; } setInputState(inputState: InputState | null) { this.clearChildren(); if (inputState == null) { this.children = []; } else { this.children = inputState.map((kana, machine) => { return new KanaMachineController(kana, machine); }); this.children.forEach(child => { child.elements.forEach(kanaElement => { this.element.appendChild(kanaElement); }); }); } } private clearChildren(): void { this.children.forEach(child => { child.elements.forEach(kanaElement => { this.element.removeChild(kanaElement); }); child.destroy(); }); } destroy(): void { this.clearChildren(); } } export class RomajiDisplayController { inputState: InputState | null; constructor( readonly firstElement: HTMLElement, readonly restElement: HTMLElement ) { this.inputState = null; } setInputState(inputState: InputState | null) { this.clearObservers(); this.inputState = inputState; if (this.inputState != null) { this.inputState.map((_, machine) => { machine.addObserver(this.observer); }); this.observer(TransitionResult.SUCCESS, false); } else { this.firstElement.textContent = ''; this.restElement.textContent = ''; } } private clearObservers(): void { if (this.inputState != null) { this.inputState.map((_, machine) => { machine.removeObserver(this.observer); }); } } observer: state.Observer = (result) => { if (result === TransitionResult.FAILED) { this.firstElement.classList.remove('error'); this.firstElement.offsetHeight; // trigger reflow this.firstElement.classList.add('error'); } else if (this.inputState !== null) { let remaining = this.inputState.getRemainingInput(); this.firstElement.textContent = remaining.charAt(0); this.restElement.textContent = remaining.substring(1); } else { this.firstElement.textContent = ''; this.restElement.textContent = ''; } } destroy(): void { this.clearObservers(); } } export class TrackProgressController { totalBar: HTMLElement; intervalBar: HTMLElement; listener: ((event: AnimationPlaybackEvent) => void) | null; constructor(private element: HTMLElement, private lines: level.Line[]) { this.totalBar = util.getElement(element, '.total .shade'); this.intervalBar = util.getElement(element, '.interval .shade'); this.listener = null; } start(start: number = 0): void { this.clearAnimations(); const end = this.lines[this.lines.length - 1].end!; const progress = start / end; this.totalBar.animate({ width: [`${progress * 100}%`, '100%'] }, { duration: (end - start) * 1000 }); for (const line of this.lines) { if (line.end! <= start) { continue; } const segmentStart = Math.max(line.start!, start); const segmentLength = line.end! - segmentStart; const fullSegmentLength = line.end! - line.start!; const progress = 1 - segmentLength / fullSegmentLength; const animation = this.intervalBar.animate({ width: [`${progress * 100}%`, '100%'] }, { delay: (segmentStart - start) * 1000, duration: segmentLength * 1000, }); if (this.listener) { animation.addEventListener('finish', this.listener); } } } pause(): void { this.totalBar.getAnimations().forEach(anim => anim.pause()); this.intervalBar.getAnimations().forEach(anim => anim.pause()); } setListener(func: (event: AnimationPlaybackEvent) => void): void { this.listener = func; } destroy(): void { this.clearAnimations(); } private clearAnimations() { this.totalBar.getAnimations().forEach(anim => anim.cancel()); this.intervalBar.getAnimations().forEach(anim => anim.cancel()); } } export class Score { combo: number = 0; score: number = 0; maxCombo: number = 0; finished: number = 0; hit: number = 0; missed: number = 0; skipped: number = 0; lastMissed: boolean = false; lastSkipped: boolean = false; intervalEnd(finished: boolean): void { if (finished) { this.finished += 1; } else { this.combo = 0; } } update(result: TransitionResult, boundary: boolean): void { if (result === TransitionResult.FAILED) { this.missed += 1; this.lastMissed = true; this.combo = 0; } else if (result === TransitionResult.SKIPPED) { this.skipped += 1; this.lastSkipped = true; this.combo = 0; } if (boundary) { if (this.lastSkipped) { // no points if we've skipped this.lastSkipped = false; return; } else if (this.lastMissed) { this.hit += 1; this.score += 50; this.lastMissed = false; } else { this.hit += 1; this.score += 100 + this.combo; } this.combo += 1; } if (this.combo > this.maxCombo) { this.maxCombo = this.combo; } } } export class ScoreController { comboElement: HTMLElement; scoreElement: HTMLElement; maxComboElement: HTMLElement; finishedElement: HTMLElement; hitElement: HTMLElement; missedElement: HTMLElement; skippedElement: HTMLElement; inputState: InputState | null = null; score: Score; constructor( private scoreContainer: HTMLElement, private statsContainer: HTMLElement ) { this.comboElement = util.getElement(scoreContainer, '.combo'); this.scoreElement = util.getElement(scoreContainer, '.score'); this.maxComboElement = util.getElement(scoreContainer, '.max-combo'); this.finishedElement = util.getElement(scoreContainer, '.finished'); this.hitElement = util.getElement(statsContainer, '.hit'); this.missedElement = util.getElement(statsContainer, '.missed'); this.skippedElement = util.getElement(statsContainer, '.skipped'); this.score = new Score(); this.setValues(); } setInputState(inputState: InputState | null): void { this.clearObservers(); this.inputState = inputState; if (this.inputState != null) { this.inputState.map((_, m) => { m.addObserver(this.observer); }); } } intervalEnd(finished: boolean): void { this.score.intervalEnd(finished); this.setValues(); } observer: state.Observer = (result, boundary) => { this.score.update(result, boundary); this.setValues(); } setValues(): void { this.comboElement.textContent = this.score.combo == 0 ? '' : this.score.combo+' combo'; this.scoreElement.textContent = this.score.score+''; this.maxComboElement.textContent = this.score.maxCombo+''; this.finishedElement.textContent = this.score.finished+''; this.hitElement.textContent = this.score.hit+''; this.missedElement.textContent = this.score.missed+''; this.skippedElement.textContent = this.score.skipped+''; } private clearObservers(): void { if (this.inputState != null) { this.inputState.map((_, machine) => { machine.removeObserver(this.observer); }); } } destroy(): void { this.clearObservers(); } } }