This commit is contained in:
sup39 2022-11-09 01:16:35 +09:00
parent f070359a42
commit b7f49d099d
18 changed files with 393 additions and 109 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 sup39
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

11
README.md Normal file
View file

@ -0,0 +1,11 @@
# botw-lang-cmp
A tool to compare cutscene times among languages in Breath of the Wild
## CREADITS
[Time data](https://github.com/sup39/botw-lang-cmp/src/db.json) is based on [Cephla's document](https://docs.google.com/document/d/1H0gqxqR2AZqc-MEDUoftJHn_FteyBBm_Zcowieev5ek/edit?usp=sharing), which is based on [Swiffy22's video](https://youtu.be/yVaZdsgjWz8).
## TODO
- [ ] Rename cutscenes
- [ ] Add more presets
- [ ] Complete Japanese translation
- [ ] Memorize settings with localStorage

View file

@ -1,7 +1,9 @@
{
"name": "nya",
"name": "botw-lang-cmp",
"version": "0.1.0",
"private": true,
"author": "sup39 <dev@sup39.dev>",
"repository": "https://github.com/sup39/botw-lang-cmp",
"license": "MIT",
"dependencies": {
"@sup39/eslint-config-typescript": "^0.1.2",
"@testing-library/jest-dom": "^5.14.1",
@ -14,6 +16,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"sass": "^1.56.0",
"typescript": "^4.4.2",
"web-vitals": "^2.1.0"
},

View file

@ -7,7 +7,7 @@
<meta name="theme-color" content="#2ee5b8" />
<meta
name="description"
content="Web site created using create-react-app"
content="A tool to compare cutscene times among languages in Breath of the Wild"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>BotW Language Comparison</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

6
src/App.module.sass Normal file
View file

@ -0,0 +1,6 @@
.SettingsRoot
> div
display: flex
margin-bottom: 0.5em
span
margin-right: 0.25em

View file

@ -1,9 +0,0 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -1,26 +1,156 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
import React, {useState, useEffect} from 'react';
import db from './db.json';
import {i18nLabels, defaultLang, $lang0, setLang} from './i18n';
import {events, i18nEventNames, presets} from './events';
import styles from './App.module.sass';
const langs = [
'English',
'French (Canada)',
'French (France)',
'German',
'Italian',
'Japanese',
'Russian',
'Spanish (Latin)',
'Spanish (Spain)',
] as const;
const dbEntries = Object.entries(db);
const f2f = (f: number) => {
let sign = '';
if (f < 0) {
sign = '-';
f = -f;
}
const sf = String(f%30)+'f';
const s = f/30|0;
if (s === 0) return sign+sf;
const ssf = `${s%30}:${sf.padStart(3, '0')}`;
const m = s/60|0;
if (m === 0) return sign+ssf;
return `${sign}${m}:${ssf.padStart(6, '0')}`;
};
const f2s = (f: number) => {
let sign = '';
if (f < 0) {
sign = '-';
f = -f;
}
const s = f/30;
const m = s/60|0;
const ss = (s-m*60).toFixed(3);
return sign+(m ? `${m}:${ss.padStart(6, '0')}` : ss);
};
export const RadioGroup = ({name, value: val, values, onChange, ...props}: {
values: (string | [value: string, label: string])[]
} & React.ComponentProps<'input'>) => <>{values.map(o => {
const [value, label] = typeof o === 'string' ? [o, o] : o;
return <div key={value} {...props}>
<input type='radio' name={name} value={value} checked={val===value}
onChange={onChange} />
<span>{label}</span>
</div>;
})}</>;
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
const [selected, setSelected] = useState(
Object.fromEntries(events.map(l => [l, false])),
);
const [timeFormat, setTimeFormat] = useState('s');
const f2t = timeFormat === 'f' ? f2f : f2s;
const [$lang, set$lang] = useState($lang0);
useEffect(() => setLang($lang), [$lang]);
const eventNames = i18nEventNames[
($lang in i18nEventNames) ? $lang : defaultLang
];
const L = (id: keyof typeof i18nLabels) =>
i18nLabels[id][$lang] ?? i18nLabels[id][defaultLang];
const selectedTimes = dbEntries.filter(([event]) => selected[event]);
const l2tEntries = langs.map(
lang => [lang, selectedTimes.reduce(
(sum, [_event, times]) => sum+times[lang],
0,
)] as const,
).sort(([_a, tA], [_b, tB]) => tA-tB);
const tMin = Math.min(...l2tEntries.map(([_, t]) => t));
return <div>
<details>
<summary>CREADITS</summary>
<div>
<p>This tool is made by sup39 with MIT license. The source code can be found <a href='https://github.com/sup39/botw-lang-cmp'>here</a>.</p>
<p><a href='https://github.com/sup39/botw-lang-cmp/src/db.json'>Time data</a> is based on <a href='https://docs.google.com/document/d/1H0gqxqR2AZqc-MEDUoftJHn_FteyBBm_Zcowieev5ek/edit?usp=sharing'>Cephla's document</a>, which is based on <a href='https://youtu.be/yVaZdsgjWz8'>Swiffy22's video</a>.</p>
</div>
</details>
<h2>Time Comparison</h2>
<section>
<table className="time">
<thead><tr>
<th>Language</th>
<th>Time</th>
<th>Diff</th>
</tr></thead>
<tbody>{l2tEntries.map(([lang, t]) => <tr key={lang}>
<td>{lang}</td>
<td>{f2t(t)}</td>
<td>{(t>tMin?'+':'±')+f2t(t-tMin)}</td>
</tr>)}</tbody>
</table>
</section>
<section>
<h3>Settings</h3>
<div className={styles.SettingsRoot}>
<div>
<span>{L('displayLang')}</span>
<select value={$lang} onChange={e=>set$lang(e.target.value)}>
<option value='en-US'>English</option>
<option value='ja-JP'></option>
</select>
</div>
<div>
<span>{L('timeFormat')}</span>
<RadioGroup name='timeFormat' value={timeFormat}
values={[['s', 'ms'], 'f']}
onChange={e => setTimeFormat(e.target.value)} />
</div>
</div>
</section>
<section>
<h3>Preset</h3>
<div>
<select
defaultValue=''
onChange={e => {
const sels = presets[e.target.value];
if (sels == null) return;
setSelected(sels);
}}
>
<option value=''>==== Choose a preset ====</option>
{Object.keys(presets).map(
name => <option key={name} value={name}>{name}</option>,
)}
</select>
</div>
</section>
<section>
<h3>Cutscenes</h3>
<div>
{events.map(id => <div key={id} className='option-ctn'>
<input
type='checkbox' checked={selected[id]}
onChange={e => setSelected(o => ({...o, [id]: e.target.checked}))}
/>
<span>{eventNames[id]}</span>
</div>)}
</div>
</section>
</div>;
}
export default App;

1
src/db.json Normal file

File diff suppressed because one or more lines are too long

90
src/events.ts Normal file
View file

@ -0,0 +1,90 @@
import type {I18N} from './i18n';
type EEvent = (typeof events)[number];
export const events = [
'Sheikah Slate Get',
'Out Of SoR',
'Head For The Point On Your SS',
'First Divine Beast Introduction',
'All Divine Beasts Completion',
'Lomei Labyrinth Island Introduction',
'South Lomei Labyrinth Introduction',
'North Lomei Labyrinth Introduction',
'Thyphlo Ruins Introduction',
'Eventide Island Introduction',
'Eventide Island Completion',
'All Shrines Completion',
'(EX) Trial Of The Sword Invitation',
'(EX) Naboris Re-Entry Introduction',
'(EX) Medoh Re-Entry Introduction',
'(EX) Ruta Re-Entry Introduction',
'(EX) Rudania Re-Entry Introduction',
'Impa',
'All 13 Memories',
"Ta'loh Naeg",
'Thundra Plateau Completion',
'(EX) Introduction',
'(EX) Obtaining The One Hit Obliterator',
'(EX) One Hit Obliterator Completion',
'(EX) Trial Of The Sword Completion',
'(EX) Single Shrine Completion',
'(EX) Vah Naboris Kass Song',
'(EX) Vah Medoh Kass Song',
'(EX) Vah Ruta Kass Song',
'(EX) Vah Rudania Kass Song',
'(EX) All Champion Songs',
'(EX) SoR Revisit Instructions',
'(EX) Final Trial Introduction',
'(EX) Final Trial Completion',
'(EX) Maz Koshia Battle Completion',
"(EX) The Champion's Ballad",
] as const;
type TEventNameMap = {[event in EEvent]: string};
const eventNameDefault =
Object.fromEntries(events.map(e => [e, e])) as TEventNameMap;
export const i18nEventNames: I18N<TEventNameMap> = {
'en-US': {
...eventNameDefault,
// 0:10
'Sheikah Slate Get': 'Sheikah Slate Get: "That is a Sheikah Slate."',
// 0:27
'Out Of SoR': 'Out of SoR: "Hold the Sheikah Slate up to the pedstal.""',
// 12:05
'Head For The Point On Your SS': '"Head for the point marked on the map in your Sheikah Slate."',
// 1:06
'First Divine Beast Introduction': 'First Divine Beast Introduction: "That Divine Beast was taken over by Ganon 100 years ago"',
// 1:46
'All Divine Beasts Completion': 'All Divine Beasts Completion: "Thanks to you, all of the Divine Beasts have returned to us and the spirits of the Champions are free."',
},
'ja-JP': {
...eventNameDefault,
'Sheikah Slate Get': 'シーカーストーン入手「それはシーカーストーン…」',
'Out Of SoR': '回生の祠を出る直前「シーカーストーンをかざすのです…」',
'Head For The Point On Your SS': '「シーカーストーンのマップに示された場所へ向かうのです…」',
'First Divine Beast Introduction': '最初の神獣イントロ「あれが今から100年前ガンに奪われてしまった神獣です…」',
'All Divine Beasts Completion': '四神獣クリア直後「ありがとう…貴方のおかげで全ての神獣と英傑達の魂が解放されました」',
},
};
type Preset = {[event in (typeof events)[number]]?: boolean};
// type Preset = (typeof events)[number][];
export const presets: {[name: string]: Preset} = Object.fromEntries([
['Any%', [
'Sheikah Slate Get',
'Head For The Point On Your SS',
]],
['All Dungeons', [
'Sheikah Slate Get',
'Head For The Point On Your SS',
'First Divine Beast Introduction',
'All Divine Beasts Completion',
]],
['Dog%', [
'Sheikah Slate Get',
'Head For The Point On Your SS',
'First Divine Beast Introduction',
]],
].map(([k, sels]) => [k, Object.fromEntries(events.map(event => [
event, sels.includes(event),
]))]));

25
src/i18n.ts Normal file
View file

@ -0,0 +1,25 @@
export const defaultLang = 'en-US';
export const lskey$lang = '$lang';
export const $lang0 = localStorage.getItem(lskey$lang) ?? defaultLang;
export function setLang($lang: string) {
localStorage.setItem(lskey$lang, $lang);
document.documentElement.lang = $lang;
}
// assert label of defaultLang is specified
export type I18N<T> =
{[lang: string]: T} & {[lang in typeof defaultLang]: T};
const _i18nLabels = {
'displayLang': {
'en-US': 'Display Language',
'ja-JP': '表示言語',
},
'timeFormat': {
'en-US': 'Time Format',
'ja-JP': 'タイムフォーマット',
},
};
export const i18nLabels: {[id in keyof typeof _i18nLabels]: I18N<string>}
= _i18nLabels;

View file

@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

32
src/index.sass Normal file
View file

@ -0,0 +1,32 @@
@import './sup39.sass'
body
margin: 1em
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
background-color: #282c34
color: white
code
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace
table, th, td
border-collapse: collapse
border: 1px solid #777
th, td
padding: 0.25em
table.time
td:nth-child(2),
td:nth-child(3)
text-align: right
min-width: 4.5em
font-variant-numeric: tabular-nums
section
margin-bottom: 1em
div.option-ctn
display: flex
align-items: flex-start
margin-bottom: 4px

View file

@ -1,8 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import './index.sass';
import App from './App';
import reportWebVitals from './reportWebVitals';
// import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
@ -16,4 +16,4 @@ root.render(
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
// reportWebVitals();

View file

@ -1,15 +0,0 @@
import {ReportHandler} from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

31
src/sup39.sass Normal file
View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 sup39
a, .link
color: #72E5DB
text-decoration: none
cursor: pointer
a:hover, .link:hover
color: #72E5DB
text-decoration: underline
a:active, .link:active
color: #A0E5DF
text-decoration: underline
details
border: 1px solid
padding: 0.5em 1em
margin-block-start: 0.5em
margin-block-end: 0.5em
> summary
padding: 4px 0.5em
margin: -0.5em -1em -0.5em
cursor: pointer
details[open]
padding-bottom: 0
> summary
border-bottom: 1px solid
margin: -0.5em -1em 0
> ul, > ol
padding-inline-start: 1.5em

View file

@ -3078,7 +3078,7 @@ check-types@^11.1.1:
resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.1.2.tgz#86a7c12bf5539f6324eb0e70ca8896c0e38f3e2f"
integrity sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ==
chokidar@^3.4.2, chokidar@^3.5.3:
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.2, chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@ -4975,6 +4975,11 @@ immer@^9.0.7:
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.16.tgz#8e7caab80118c2b54b37ad43e05758cdefad0198"
integrity sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==
immutable@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@ -7832,6 +7837,15 @@ sass-loader@^12.3.0:
klona "^2.0.4"
neo-async "^2.6.2"
sass@^1.56.0:
version "1.56.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.56.0.tgz#134032075a3223c8d49cb5c35e091e5ba1de8e0a"
integrity sha512-WFJ9XrpkcnqZcYuLRJh5qiV6ibQOR4AezleeEjTjMsCocYW59dEG19U3fwTTXxzi2Ed3yjPBp727hbbj53pHFw==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
sax@~1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@ -8038,7 +8052,7 @@ source-list-map@^2.0.0, source-list-map@^2.0.1:
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
source-map-js@^1.0.1, source-map-js@^1.0.2:
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==