display.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. /*
  2. * This module handles displaying the UI. The most important one is the main
  3. * area which contains the text to be typed in. Progress is displayed on the
  4. * kana part of the area, while errors are shown via the romaji section. The
  5. * kanji is simply just for reading.
  6. */
  7. import { KanaInputState as InputState } from './kana';
  8. import * as state from './state';
  9. import { TransitionResult } from './state';
  10. import * as level from './level';
  11. import * as util from './util';
  12. class SingleKanaDisplayComponent {
  13. element: HTMLElement;
  14. finished: boolean;
  15. constructor(kana: string) {
  16. this.element = document.createElement('span');
  17. this.element.classList.add('kana');
  18. this.element.textContent = kana;
  19. this.element.setAttribute('data-text', kana);
  20. this.finished = false;
  21. }
  22. setPartial() {
  23. if (!this.finished) {
  24. this.element.classList.add('half');
  25. }
  26. }
  27. setFull() {
  28. this.finished = true;
  29. this.element.classList.remove('half');
  30. this.element.classList.add('full');
  31. }
  32. }
  33. class KanaMachineController {
  34. state: state.StateMachine;
  35. children: SingleKanaDisplayComponent[];
  36. current: number;
  37. get elements() {
  38. return this.children.map((kanaComponent) => kanaComponent.element);
  39. }
  40. constructor(kana: string, state: state.StateMachine) {
  41. this.state = state;
  42. this.current = 0;
  43. this.state.addObserver(this.observer);
  44. this.children = kana
  45. .split('')
  46. .map((c) => new SingleKanaDisplayComponent(c));
  47. }
  48. observer: state.Observer<number> = (result, meta) => {
  49. if (meta > this.current) {
  50. while (this.current < meta) {
  51. this.children[this.current].setFull();
  52. this.current += 1;
  53. }
  54. } else if (result != TransitionResult.FAILED) {
  55. this.children[this.current].setPartial();
  56. }
  57. };
  58. destroy(): void {
  59. this.state.removeObserver(this.observer);
  60. }
  61. }
  62. export class KanaDisplayController {
  63. children: KanaMachineController[];
  64. constructor(readonly element: HTMLElement) {
  65. this.children = [];
  66. }
  67. setInputState(inputState: InputState | null) {
  68. this.clearChildren();
  69. if (inputState == null) {
  70. this.children = [];
  71. } else {
  72. this.children = inputState.map((kana, machine) => {
  73. return new KanaMachineController(kana, machine);
  74. });
  75. this.children.forEach((child) => {
  76. child.elements.forEach((kanaElement) => {
  77. this.element.appendChild(kanaElement);
  78. });
  79. });
  80. }
  81. }
  82. private clearChildren(): void {
  83. this.children.forEach((child) => {
  84. child.elements.forEach((kanaElement) => {
  85. this.element.removeChild(kanaElement);
  86. });
  87. child.destroy();
  88. });
  89. }
  90. destroy(): void {
  91. this.clearChildren();
  92. }
  93. }
  94. export class RomajiDisplayController {
  95. inputState: InputState | null;
  96. constructor(
  97. readonly firstElement: HTMLElement,
  98. readonly restElement: HTMLElement
  99. ) {
  100. this.inputState = null;
  101. }
  102. setInputState(inputState: InputState | null) {
  103. this.clearObservers();
  104. this.inputState = inputState;
  105. if (this.inputState != null) {
  106. this.inputState.map((_, machine) => {
  107. machine.addObserver(this.observer);
  108. });
  109. this.observer(TransitionResult.SUCCESS, 0, false);
  110. } else {
  111. this.firstElement.textContent = '';
  112. this.restElement.textContent = '';
  113. }
  114. }
  115. private clearObservers(): void {
  116. if (this.inputState != null) {
  117. this.inputState.map((_, machine) => {
  118. machine.removeObserver(this.observer);
  119. });
  120. }
  121. }
  122. observer: state.Observer<number> = (result) => {
  123. if (result === TransitionResult.FAILED) {
  124. this.firstElement.classList.remove('error');
  125. this.firstElement.offsetHeight; // trigger reflow
  126. this.firstElement.classList.add('error');
  127. } else if (this.inputState !== null) {
  128. let remaining = this.inputState.getRemainingInput();
  129. this.firstElement.textContent = remaining.charAt(0);
  130. this.restElement.textContent = remaining.substring(1);
  131. } else {
  132. this.firstElement.textContent = '';
  133. this.restElement.textContent = '';
  134. }
  135. };
  136. destroy(): void {
  137. this.clearObservers();
  138. }
  139. }
  140. export class TrackProgressController {
  141. totalBar: HTMLElement;
  142. intervalBar: HTMLElement;
  143. listener: ((event: AnimationPlaybackEvent) => void) | null;
  144. constructor(private element: HTMLElement, private lines: level.Line[]) {
  145. this.totalBar = util.getElement(element, '.total .shade');
  146. this.intervalBar = util.getElement(element, '.interval .shade');
  147. this.listener = null;
  148. }
  149. start(start: number = 0): void {
  150. this.clearAnimations();
  151. const end = this.lines[this.lines.length - 1].end!;
  152. const progress = start / end;
  153. this.totalBar.animate(
  154. { width: [`${progress * 100}%`, '100%'] },
  155. {
  156. duration: (end - start) * 1000,
  157. }
  158. );
  159. for (const line of this.lines) {
  160. if (line.end! <= start) {
  161. continue;
  162. }
  163. const segmentStart = Math.max(line.start!, start);
  164. const segmentLength = line.end! - segmentStart;
  165. const fullSegmentLength = line.end! - line.start!;
  166. const progress = 1 - segmentLength / fullSegmentLength;
  167. const animation = this.intervalBar.animate(
  168. { width: [`${progress * 100}%`, '100%'] },
  169. {
  170. delay: (segmentStart - start) * 1000,
  171. duration: segmentLength * 1000,
  172. }
  173. );
  174. if (this.listener) {
  175. animation.addEventListener('finish', this.listener);
  176. }
  177. }
  178. }
  179. pause(): void {
  180. this.totalBar.getAnimations().forEach((anim) => anim.pause());
  181. this.intervalBar.getAnimations().forEach((anim) => anim.pause());
  182. }
  183. setListener(func: (event: AnimationPlaybackEvent) => void): void {
  184. this.listener = func;
  185. }
  186. destroy(): void {
  187. this.clearAnimations();
  188. }
  189. private clearAnimations() {
  190. this.totalBar.getAnimations().forEach((anim) => anim.cancel());
  191. this.intervalBar.getAnimations().forEach((anim) => anim.cancel());
  192. }
  193. }
  194. export class Score {
  195. combo: number = 0;
  196. score: number = 0;
  197. maxCombo: number = 0;
  198. finished: number = 0;
  199. hit: number = 0;
  200. missed: number = 0;
  201. skipped: number = 0;
  202. lastMissed: boolean = false;
  203. lastSkipped: boolean = false;
  204. intervalEnd(finished: boolean): void {
  205. if (finished) {
  206. this.finished += 1;
  207. } else {
  208. this.combo = 0;
  209. }
  210. }
  211. update(result: TransitionResult, points: number): void {
  212. if (result === TransitionResult.FAILED) {
  213. this.missed += 1;
  214. this.lastMissed = true;
  215. this.combo = 0;
  216. } else if (result === TransitionResult.SKIPPED) {
  217. this.skipped += 1;
  218. this.lastSkipped = true;
  219. this.combo = 0;
  220. }
  221. for (let i = 0; i < points; ++i) {
  222. if (this.lastSkipped) {
  223. // no points if we've skipped
  224. this.lastSkipped = false;
  225. return;
  226. } else if (this.lastMissed) {
  227. this.hit += 1;
  228. this.score += 50;
  229. this.lastMissed = false;
  230. } else {
  231. this.hit += 1;
  232. this.score += 100 + this.combo;
  233. }
  234. this.combo += 1;
  235. }
  236. if (this.combo > this.maxCombo) {
  237. this.maxCombo = this.combo;
  238. }
  239. }
  240. }
  241. export class ScoreController {
  242. comboElement: HTMLElement;
  243. scoreElement: HTMLElement;
  244. maxComboElement: HTMLElement;
  245. finishedElement: HTMLElement;
  246. hitElement: HTMLElement;
  247. missedElement: HTMLElement;
  248. skippedElement: HTMLElement;
  249. inputState: InputState | null = null;
  250. lastMeta: number;
  251. score: Score;
  252. constructor(
  253. private scoreContainer: HTMLElement,
  254. private statsContainer: HTMLElement
  255. ) {
  256. this.comboElement = util.getElement(scoreContainer, '.combo');
  257. this.scoreElement = util.getElement(scoreContainer, '.score');
  258. this.maxComboElement = util.getElement(scoreContainer, '.max-combo');
  259. this.finishedElement = util.getElement(scoreContainer, '.finished');
  260. this.hitElement = util.getElement(statsContainer, '.hit');
  261. this.missedElement = util.getElement(statsContainer, '.missed');
  262. this.skippedElement = util.getElement(statsContainer, '.skipped');
  263. this.lastMeta = 0;
  264. this.score = new Score();
  265. this.setValues();
  266. }
  267. setInputState(inputState: InputState | null): void {
  268. this.clearObservers();
  269. this.inputState = inputState;
  270. if (this.inputState != null) {
  271. this.inputState.map((_, m) => {
  272. m.addObserver(this.observer);
  273. });
  274. }
  275. }
  276. intervalEnd(finished: boolean): void {
  277. this.score.intervalEnd(finished);
  278. this.setValues();
  279. }
  280. observer: state.Observer<number> = (result, meta, finished) => {
  281. const points = Math.max(0, meta - this.lastMeta);
  282. this.lastMeta = finished ? 0 : meta;
  283. this.score.update(result, points);
  284. this.setValues();
  285. };
  286. setValues(): void {
  287. this.comboElement.textContent =
  288. this.score.combo == 0 ? '' : this.score.combo + ' combo';
  289. this.scoreElement.textContent = this.score.score + '';
  290. this.maxComboElement.textContent = this.score.maxCombo + '';
  291. this.finishedElement.textContent = this.score.finished + '';
  292. this.hitElement.textContent = this.score.hit + '';
  293. this.missedElement.textContent = this.score.missed + '';
  294. this.skippedElement.textContent = this.score.skipped + '';
  295. }
  296. private clearObservers(): void {
  297. if (this.inputState != null) {
  298. this.inputState.map((_, machine) => {
  299. machine.removeObserver(this.observer);
  300. });
  301. }
  302. }
  303. destroy(): void {
  304. this.clearObservers();
  305. }
  306. }