typing.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import * as audio from '../audio';
  2. import * as display from '../display';
  3. import * as level from '../level';
  4. import * as kana from '../kana';
  5. import * as util from '../util';
  6. import * as youtube from '../youtube';
  7. import { GameContext, Screen, ScreenManager } from './common';
  8. import Level = level.Level;
  9. class TypingScreenContext {
  10. track: audio.Track | null = null;
  11. constructor(
  12. readonly context: GameContext,
  13. readonly level: Level,
  14. readonly switchClosure: (screen: Screen | null) => void
  15. ) {}
  16. get container() {
  17. return this.context.container;
  18. }
  19. get audioManager() {
  20. return this.context.audioManager;
  21. }
  22. get bgManager() {
  23. return this.context.bgManager;
  24. }
  25. switchScreen(screen: Screen | null): void {
  26. this.switchClosure(screen);
  27. }
  28. }
  29. export class TypingScreen extends ScreenManager implements Screen {
  30. readonly name: string = 'game';
  31. constructor(
  32. readonly context: GameContext,
  33. readonly level: Level,
  34. readonly prevScreen: Screen
  35. ) {
  36. super(context.container);
  37. }
  38. enter(): void {
  39. if (this.level.background) {
  40. this.context.bgManager.setBackground(this.level.background);
  41. }
  42. let context = new TypingScreenContext(this.context, this.level, (screen) =>
  43. this.switchScreen(screen)
  44. );
  45. let loadingScreen = new TypingLoadingScreen(context);
  46. this.switchScreen(loadingScreen);
  47. }
  48. handleInput(key: string): void {
  49. if (this.activeScreen !== null) {
  50. this.activeScreen.handleInput(key);
  51. }
  52. }
  53. switchScreen(screen: Screen | null): void {
  54. super.switchScreen(screen);
  55. if (screen == null) {
  56. this.context.switchScreen(this.prevScreen);
  57. }
  58. }
  59. exit(): void {}
  60. transitionExit(): void {}
  61. }
  62. class TypingLoadingScreen implements Screen {
  63. readonly name: string = 'game-loading';
  64. barElement: HTMLElement | null = null;
  65. textElement: HTMLElement | null = null;
  66. readyElement: HTMLElement | null = null;
  67. isReady: boolean = false;
  68. fnContext: util.FnContext = new util.FnContext();
  69. constructor(readonly context: TypingScreenContext) {}
  70. enter(): void {
  71. let loader: HTMLElement = util.getElement(
  72. this.context.container,
  73. '#loader'
  74. );
  75. this.readyElement = util.getElement(this.context.container, '#ready');
  76. if (this.context.level.audio != null) {
  77. loader.style.visibility = 'visible';
  78. this.barElement = util.getElement(loader, '.progress-bar .shade');
  79. this.textElement = util.getElement(loader, '.label');
  80. this.barElement.style.width = '0%';
  81. this.textElement.textContent = 'music loading';
  82. this.readyElement.querySelector('.status')!.textContent = 'Loading';
  83. this.readyElement.querySelector('.message')!.textContent = 'please wait';
  84. this.fnContext.invalidate();
  85. const videoId = youtube.getVideoId(this.context.level.audio);
  86. const progressListener = this.fnContext.wrap((percentage: number) => {
  87. this.barElement!.style.width = `${percentage}%`;
  88. });
  89. let trackPromise: Promise<audio.Track>;
  90. if (videoId !== null) {
  91. const ytElement = document.createElement('div');
  92. trackPromise = this.context.audioManager.loadTrackFromYoutube(
  93. videoId,
  94. ytElement,
  95. progressListener
  96. );
  97. this.context.bgManager.setVideo(ytElement);
  98. if (this.context.level.background == undefined) {
  99. trackPromise.then((track) => {
  100. track.addListener((_, state) => {
  101. if (state === audio.PlayState.PLAYING) {
  102. this.context.bgManager.showVideo();
  103. }
  104. });
  105. });
  106. }
  107. } else {
  108. trackPromise = this.context.audioManager.loadTrackWithProgress(
  109. this.context.level.audio,
  110. progressListener
  111. );
  112. }
  113. trackPromise.then(
  114. this.fnContext.wrap((track: audio.Track) => {
  115. this.context.track = track;
  116. this.barElement!.style.width = '100%';
  117. this.textElement!.textContent = 'music loaded';
  118. this.setReady();
  119. })
  120. );
  121. } else {
  122. loader.style.visibility = 'hidden';
  123. this.setReady();
  124. }
  125. }
  126. setReady(): void {
  127. this.readyElement!.querySelector('.status')!.textContent = 'Ready';
  128. this.readyElement!.querySelector('.message')!.textContent =
  129. 'press space to start';
  130. this.isReady = true;
  131. }
  132. handleInput(key: string): void {
  133. if (key === 'Escape' || key === 'Backspace') {
  134. this.context.switchScreen(null);
  135. } else if (this.isReady && (key === ' ' || key === 'Enter')) {
  136. this.context.switchScreen(new TypingPlayingScreen(this.context));
  137. }
  138. }
  139. exit(): void {
  140. this.fnContext.invalidate();
  141. }
  142. transitionExit(): void {
  143. if (this.barElement) {
  144. this.barElement.style.width = '0%';
  145. }
  146. }
  147. }
  148. class TypingPlayingScreen implements Screen {
  149. readonly name: string = 'game-playing';
  150. gameContainer: HTMLElement;
  151. currentIndex: number;
  152. inputState: kana.KanaInputState | null;
  153. isWaiting: boolean;
  154. skippable: boolean;
  155. kanjiElement: HTMLElement;
  156. kanaController: display.KanaDisplayController;
  157. romajiController: display.RomajiDisplayController;
  158. progressController: display.TrackProgressController | null;
  159. scoreController: display.ScoreController;
  160. lines: level.Line[];
  161. constructor(readonly context: TypingScreenContext) {
  162. this.gameContainer = util.getElement(this.context.container, '#game');
  163. this.currentIndex = -1;
  164. this.inputState = null;
  165. this.isWaiting = false;
  166. this.skippable = false;
  167. this.kanjiElement = util.getElement(this.gameContainer, '.kanji-line');
  168. this.romajiController = new display.RomajiDisplayController(
  169. util.getElement(this.gameContainer, '.romaji-first'),
  170. util.getElement(this.gameContainer, '.romaji-line')
  171. );
  172. this.kanaController = new display.KanaDisplayController(
  173. util.getElement(this.gameContainer, '.kana-line')
  174. );
  175. this.progressController = null;
  176. this.scoreController = new display.ScoreController(
  177. util.getElement(this.gameContainer, '.score-line'),
  178. util.getElement(this.gameContainer, '.stats-line')
  179. );
  180. this.lines = this.context.level.lines;
  181. }
  182. enter(): void {
  183. let progressElement: HTMLElement = this.gameContainer.querySelector<HTMLElement>(
  184. '.track-progress'
  185. )!;
  186. if (this.context.track == null) {
  187. progressElement.style.visibility = 'hidden';
  188. this.lines = this.context.level.lines.filter((line) => line.kana != '@');
  189. } else {
  190. progressElement.style.visibility = 'visible';
  191. const progressController = new display.TrackProgressController(
  192. progressElement,
  193. this.lines
  194. );
  195. progressController.setListener((_) => this.onIntervalEnd());
  196. this.context.track.addListener((track, state) => {
  197. if (state === audio.PlayState.PLAYING) {
  198. progressController.start(track.getTime());
  199. } else {
  200. progressController.pause();
  201. }
  202. });
  203. this.progressController = progressController;
  204. }
  205. this.onStart();
  206. }
  207. get currentLine() {
  208. return this.lines[this.currentIndex];
  209. }
  210. setWaiting(waiting: boolean, skippable: boolean = false): void {
  211. this.isWaiting = waiting;
  212. this.skippable = waiting && skippable;
  213. this.gameContainer.classList.toggle('waiting', this.isWaiting);
  214. this.gameContainer.classList.toggle('skippable', this.skippable);
  215. }
  216. onStart(): void {
  217. this.nextLine();
  218. if (this.context.track !== null) {
  219. this.context.track.start(0);
  220. }
  221. this.setWaiting(false);
  222. this.checkComplete();
  223. }
  224. checkComplete(): void {
  225. let currentLine = this.currentLine;
  226. if (
  227. currentLine != null &&
  228. currentLine.kana == '@' &&
  229. currentLine.kanji == '@'
  230. ) {
  231. this.onComplete(true);
  232. }
  233. }
  234. onIntervalEnd(): void {
  235. if (this.isWaiting) {
  236. this.setWaiting(false);
  237. } else {
  238. this.nextLine();
  239. this.scoreController.intervalEnd(false);
  240. }
  241. if (this.currentIndex >= this.lines.length) {
  242. this.finish();
  243. }
  244. this.checkComplete();
  245. }
  246. onComplete(autoComplete: boolean = false): void {
  247. this.nextLine();
  248. if (!autoComplete) {
  249. this.scoreController.intervalEnd(true);
  250. }
  251. if (this.context.track !== null) {
  252. // skippable if the last line was empty and the current line is longer
  253. // than 3 seconds
  254. const lastLine = this.lines[this.currentIndex - 1];
  255. const skippable =
  256. autoComplete &&
  257. lastLine !== undefined &&
  258. lastLine.end! - lastLine.start! > 3;
  259. this.setWaiting(true, skippable);
  260. } else {
  261. if (this.currentIndex >= this.lines.length) {
  262. this.finish();
  263. }
  264. }
  265. }
  266. handleInput(key: string): void {
  267. if (key === 'Escape' || key === 'Backspace') {
  268. this.finish();
  269. return;
  270. } else if (!this.isWaiting) {
  271. if (this.inputState !== null && /^[-_ a-z]$/.test(key)) {
  272. if (this.inputState.handleInput(key)) {
  273. this.onComplete();
  274. }
  275. }
  276. } else if (this.skippable && key === 'Tab' && this.context.track !== null) {
  277. const start = this.currentLine.start!;
  278. if (start - this.context.track.getTime() > 3) {
  279. this.context.track.start(start - 1.5);
  280. }
  281. }
  282. }
  283. nextLine(): void {
  284. if (this.currentIndex < this.lines.length) {
  285. this.currentIndex += 1;
  286. }
  287. if (this.currentIndex < this.lines.length) {
  288. this.setLine(this.lines[this.currentIndex]);
  289. } else {
  290. this.setLine({ kanji: '@', kana: '@' });
  291. }
  292. }
  293. setLine(line: level.Line): void {
  294. let kanji, inputState;
  295. if (line.kanji === '@') {
  296. kanji = '';
  297. } else {
  298. kanji = line.kanji;
  299. }
  300. if (line.kana === '@') {
  301. inputState = null;
  302. } else {
  303. inputState = new kana.KanaInputState(line.kana);
  304. }
  305. this.inputState = inputState;
  306. this.kanjiElement.textContent = kanji;
  307. this.kanaController.setInputState(this.inputState);
  308. this.romajiController.setInputState(this.inputState);
  309. this.scoreController.setInputState(this.inputState);
  310. }
  311. finish(): void {
  312. this.context.switchScreen(
  313. new TypingFinishScreen(this.context, this.scoreController.score)
  314. );
  315. }
  316. exit(): void {}
  317. transitionExit(): void {
  318. this.gameContainer.classList.remove('skippable');
  319. this.kanaController.destroy();
  320. this.romajiController.destroy();
  321. if (this.context.track !== null) {
  322. this.progressController!.destroy();
  323. this.context.track.clearListeners();
  324. }
  325. this.scoreController.destroy();
  326. }
  327. }
  328. class TypingFinishScreen implements Screen {
  329. name: string = 'game-finished';
  330. constructor(
  331. readonly context: TypingScreenContext,
  332. readonly score: display.Score
  333. ) {
  334. let container = this.context.container.querySelector('#score')!;
  335. container.querySelector('.score')!.textContent = this.score.score + '';
  336. container.querySelector('.max-combo')!.textContent =
  337. this.score.maxCombo + '';
  338. container.querySelector('.finished')!.textContent =
  339. this.score.finished + '';
  340. container.querySelector('.hit')!.textContent = this.score.hit + '';
  341. container.querySelector('.missed')!.textContent = this.score.missed + '';
  342. container.querySelector('.skipped')!.textContent = this.score.skipped + '';
  343. }
  344. enter(): void {}
  345. handleInput(key: string): void {
  346. if (
  347. key === ' ' ||
  348. key === 'Enter' ||
  349. key === 'Escape' ||
  350. key === 'Backspace'
  351. ) {
  352. this.context.switchScreen(null);
  353. }
  354. }
  355. exit(): void {}
  356. transitionExit(): void {
  357. if (this.context.track !== null) {
  358. this.context.track.exit();
  359. }
  360. }
  361. }