level.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * This module represents the levels for the game. Each level consists of lines
  3. * that you have to complete. Each line has the kanji of the line, which is used
  4. * solely for display and the kana of the line which the input is based.
  5. */
  6. import { KANA_REGEX } from './kana';
  7. export interface Line {
  8. kanji: string;
  9. kana: string;
  10. start?: number;
  11. end?: number;
  12. }
  13. export interface Level {
  14. name: string;
  15. creator: string | null;
  16. genre: string | null;
  17. difficulty: string | null;
  18. audio: string | null;
  19. background?: string | null;
  20. songLink?: string;
  21. lines: Line[];
  22. }
  23. export interface LevelSet {
  24. name: string;
  25. levels: Level[];
  26. }
  27. export interface Config {
  28. background: string;
  29. selectMusic: string | null;
  30. selectSound: string;
  31. decideSound: string;
  32. baseColor: string;
  33. highlightColor: string;
  34. contrastColor: string;
  35. levelSets: LevelSet[];
  36. }
  37. export async function loadFromLocalStorage(): Promise<Config> {
  38. const text = localStorage.getItem('LEVELS_JSON');
  39. if (text === null) {
  40. throw new Error('No LEVELS_JSON in local storage');
  41. } else {
  42. return JSON.parse(text);
  43. }
  44. }
  45. export async function loadFromJson(url: string): Promise<Config> {
  46. const response = await window.fetch(url);
  47. return await response.json();
  48. }
  49. let parser = new DOMParser();
  50. async function parseXML(response: Response): Promise<Document> {
  51. const text = await response.text();
  52. let normalized = text.replace(/[“”]/g, '"');
  53. return parser.parseFromString(normalized, 'text/xml');
  54. }
  55. export async function loadFromTM(base: string): Promise<Config> {
  56. let settingsXML = window.fetch(base + '/settings.xml').then(parseXML);
  57. let levelSets = window
  58. .fetch(base + '/folderlist.xml')
  59. .then(parseXML)
  60. .then((dom) => parseTMFolderList(base, dom));
  61. const [settings, levels] = await Promise.all([settingsXML, levelSets]);
  62. return parseTMSettings(base, levels, settings);
  63. }
  64. function parseTMSettings(
  65. base: string,
  66. levelSets: LevelSet[],
  67. dom: Document
  68. ): Config {
  69. function getData(tag: string): string | null {
  70. let elem = dom.querySelector(tag);
  71. if (elem === null) {
  72. return null;
  73. } else {
  74. return base + '/' + elem.getAttribute('src');
  75. }
  76. }
  77. let background = getData('background');
  78. let selectMusic = getData('selectmusic');
  79. let selectSound = getData('selectsound');
  80. let decideSound = getData('decidesound');
  81. if (background === null) {
  82. throw new Error('background is not set');
  83. }
  84. if (decideSound === null) {
  85. throw new Error('decidesound is not set');
  86. }
  87. if (selectSound === null) {
  88. throw new Error('selectsound is not set');
  89. }
  90. return {
  91. background,
  92. baseColor: 'white',
  93. highlightColor: 'blue',
  94. contrastColor: 'black',
  95. selectMusic,
  96. selectSound,
  97. decideSound,
  98. levelSets,
  99. };
  100. }
  101. function parseTMFolderList(base: string, dom: Document): Promise<LevelSet[]> {
  102. let folderList = dom.querySelectorAll('folder');
  103. let promises = [];
  104. for (let i = 0; i < folderList.length; ++i) {
  105. let folder = folderList[i];
  106. let name = folder.getAttribute('name');
  107. let path = folder.getAttribute('path');
  108. if (name === null || path === null) {
  109. console.warn(`Invalid folder entry ${name} with path ${path}`);
  110. continue;
  111. }
  112. let promise = window
  113. .fetch(base + '/' + path)
  114. .then(parseXML)
  115. .then((dom) => parseTMFolder(base, name!, dom));
  116. promises.push(promise);
  117. }
  118. return Promise.all(promises);
  119. }
  120. async function parseTMFolder(
  121. base: string,
  122. name: string,
  123. dom: Document
  124. ): Promise<LevelSet> {
  125. let musicList = dom.querySelectorAll('musicinfo');
  126. let promises = [];
  127. for (let i = 0; i < musicList.length; ++i) {
  128. let musicInfo = musicList[i];
  129. let xmlPath = base + '/' + musicInfo.getAttribute('xmlpath');
  130. let audioPath = base + '/' + musicInfo.getAttribute('musicpath');
  131. function getData(tag: string): string | null {
  132. let elem = musicInfo.querySelector(tag);
  133. if (elem === null) {
  134. return null;
  135. } else {
  136. return elem.textContent;
  137. }
  138. }
  139. let name = getData('musicname') || '[Unknown]';
  140. let creator = getData('artist');
  141. let genre = getData('genre');
  142. let difficulty = getData('level');
  143. let promise = window
  144. .fetch(xmlPath)
  145. .then(parseXML)
  146. .then(parseTMSong)
  147. .then((lines) => {
  148. return {
  149. name,
  150. creator,
  151. genre,
  152. difficulty,
  153. audio: audioPath,
  154. lines,
  155. };
  156. });
  157. promises.push(promise);
  158. }
  159. const levels = await Promise.all(promises);
  160. return { name, levels };
  161. }
  162. function parseTMSong(dom: Document): Line[] {
  163. let kanjiList = dom.querySelectorAll('nihongoword');
  164. let kanaList = dom.querySelectorAll('word');
  165. let intervalList = dom.querySelectorAll('interval');
  166. let lines: Line[] = [];
  167. let time = 0;
  168. for (let i = 0; i < intervalList.length; ++i) {
  169. let start = time;
  170. const interval = intervalList[i].textContent;
  171. if (interval === null) {
  172. throw new Error(`Invalid interval: ${interval}`);
  173. }
  174. time += parseInt(interval) / 1000;
  175. lines.push({
  176. kanji: kanjiList[i].textContent || '',
  177. kana: kanaList[i].textContent || '',
  178. start: start,
  179. end: time,
  180. });
  181. }
  182. return lines;
  183. }
  184. interface LevelSpeed {
  185. lines: number;
  186. kana: number;
  187. average: number;
  188. maximum: number;
  189. }
  190. export function calculateLines(level: Level): LevelSpeed {
  191. const lines = level.lines.length;
  192. const kana = level.lines.reduce((acc, line) => acc + countKana(line.kana), 0);
  193. return {
  194. lines,
  195. kana,
  196. average: -1,
  197. maximum: -1,
  198. };
  199. }
  200. export function calculateSpeed(level: Level): LevelSpeed {
  201. let count = 0;
  202. let maximum = 0;
  203. let total = 0;
  204. let kanaTotal = 0;
  205. for (const line of level.lines) {
  206. if (line.kana === '' || line.kana === '@') {
  207. continue;
  208. }
  209. if (line.start === undefined || line.end === undefined) {
  210. continue;
  211. }
  212. const kanaCount = countKana(line.kana);
  213. const duration = line.end - line.start;
  214. const lineSpeed = kanaCount / duration;
  215. count += 1;
  216. maximum = Math.max(maximum, lineSpeed);
  217. total += lineSpeed;
  218. kanaTotal += kanaCount;
  219. }
  220. const average = total / count;
  221. return {
  222. lines: count,
  223. kana: kanaTotal,
  224. average,
  225. maximum,
  226. };
  227. }
  228. function countKana(input: string): number {
  229. return input.split('').reduce((acc, c) => {
  230. // non-kana is counted as half
  231. return (KANA_REGEX.test(c) ? 1 : 0.5) + acc;
  232. }, 0);
  233. }