Compare commits

...

7 Commits

7 changed files with 225 additions and 24 deletions
Binary file not shown.
Binary file not shown.
+56 -2
View File
@@ -1,3 +1,57 @@
# numnum
# NumNum
тренер систем счисления
Numeral systems trainer PWA
**Demo:** [numnum.atroshin.ru](https://numnum.atroshin.ru)
## Features
- 10 numeral systems: Binary, Ternary, Balanced Ternary, Octal, Hexadecimal, Braille Decimal, Roman, Church Slavonic, Greek, Hebrew
- Two game modes: multiple choice and manual entry (numpad)
- Configurable number range, time limit (1s30s or no limit), question count (5100)
- Installable PWA with full offline support (theory content cached)
- Dark/light theme · Bilingual (English / Русский)
- In-app theory reference for each system
## Usage
Serve the directory with any HTTP server:
```
python3 -m http.server 8080
```
## Project structure
```
index.html — Entry point
app.js — Game logic and UI
numerals.js — Numeral system converters and choice generator
styles.css — Styles, dark/light theme
sw.js — Service worker (PWA caching)
manifest.json — PWA manifest
theory/ — Theory HTML files (10 systems × 2 languages)
```
## Supported systems
| System | Type | Range |
|---|---|---|
| Binary | Positional | 132768 |
| Ternary | Positional | 127 |
| Balanced Ternary | Positional | 2727 |
| Octal | Positional | 14096 |
| Hexadecimal | Positional | 165536 |
| Braille Decimal | Positional | 09999 |
| Roman | Non-positional | 13999 |
| Church Slavonic | Non-positional | 19999 |
| Greek | Non-positional | 19999 |
| Hebrew | Non-positional | 19999 |
## Development
Theory HTML files live in `theory/`. After editing them, bump the version in `sw.js` to refresh the cache on next install.
## License
MIT
+105 -12
View File
@@ -34,6 +34,7 @@ const STRINGS = {
congratulations: 'Congratulations!',
pleaseSelect: 'Please select all options',
installApp: 'Install App',
theoryUnavailable: 'Content unavailable',
sec: 'sec',
chooseAnswer: 'Choose answer',
enterEquivalent: 'Enter equivalent',
@@ -75,6 +76,7 @@ const STRINGS = {
congratulations: '\u041F\u043E\u0437\u0434\u0440\u0430\u0432\u043B\u044F\u0435\u043C!',
pleaseSelect: '\u041F\u043E\u0436\u0430\u043B\u0443\u0439\u0441\u0442\u0430, \u0432\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0432\u0441\u0435 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B',
installApp: '\u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u044C \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435',
theoryUnavailable: '\u041C\u0430\u0442\u0435\u0440\u0438\u0430\u043B \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D',
sec: '\u0441\u0435\u043A',
chooseAnswer: '\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u043E\u0442\u0432\u0435\u0442',
enterEquivalent: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0447\u0438\u0441\u043B\u043E',
@@ -113,6 +115,55 @@ function applyTranslations() {
});
}
function refreshDynamicText() {
const active = document.querySelector('.screen.active');
if (!active) return;
switch (active.id) {
case 'screen-setup-system':
renderSetupSystem();
break;
case 'screen-setup-range':
renderSetupRange();
break;
case 'screen-setup-options':
document.querySelectorAll('#setup-modes .option-btn').forEach((btn, i) => {
btn.textContent = t(MODES[i].labelKey);
});
[...document.getElementById('setup-timing').options].forEach((opt, i) => {
opt.textContent = TIMINGS[i].value === 0 ? t('noLimit') : TIMINGS[i].value + ' ' + t('sec');
});
break;
case 'screen-theory-system':
renderTheorySystem();
break;
case 'screen-theory-content':
document.getElementById('theory-title').textContent = systemName(lastTheoryKey);
break;
case 'screen-game':
if (game.system && game.range) {
document.getElementById('score-system-range').textContent = systemName(game.system) + ': ' + game.range.label;
}
break;
case 'screen-results': {
const completed = game.currentRound >= game.rounds;
const total = completed ? game.rounds : game.currentRound;
const pct = total > 0 ? Math.round((game.correctCount / total) * 100) : 0;
document.getElementById('results-summary').innerHTML =
'<div class="big-number">' + game.correctCount + '/' + total + '</div>' +
'<div>' + t('pctCorrect', {pct: pct}) + '</div>';
const labelEl = document.getElementById('results-mistakes-label');
if (game.mistakes.length > 0 || labelEl.style.display !== 'none') {
labelEl.textContent = t('mistakes');
}
const congratsEl = document.getElementById('results-congrats');
if (congratsEl.textContent) {
congratsEl.textContent = t('congratulations');
}
break;
}
}
}
const SYSTEM_RANGES = {
binary: [
{ label: '2\u2070 \u2013 2\u00B3', min: 1, max: 8 },
@@ -188,6 +239,7 @@ const SYSTEM_GROUPS = {
};
const theoryCache = {};
let lastTheoryKey = null;
const MODES = [
{ labelKey: 'choose', value: 'choice' },
@@ -221,9 +273,23 @@ let game = {
mistakes: [],
};
let popstateHandled = false;
let navDepth = 0;
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + id).classList.add('active');
if (id === 'menu') {
if (navDepth > 0) {
const n = navDepth;
navDepth = 0;
history.go(-n);
}
} else if (!popstateHandled) {
navDepth++;
history.pushState({ screen: id }, '');
}
popstateHandled = false;
}
function shuffle(arr) {
@@ -348,7 +414,7 @@ const MARK_INCORRECT = '\u2717 ';
function startGame() {
lastSetupState = { ...setupState };
game.system = setupState.system;
game.range = setupState.rangeData || RANGES[setupState.range];
game.range = setupState.rangeData;
game.mode = MODES[setupState.mode]?.value ?? setupState.mode;
game.timing = TIMINGS[setupState.timing]?.value ?? setupState.timing;
game.rounds = ROUNDS[setupState.rounds];
@@ -748,12 +814,11 @@ function renderTheorySystem() {
}
async function showTheoryContent(key) {
lastTheoryKey = key;
const html = await loadTheory(key);
if (!html) return;
document.getElementById('theory-title').textContent = systemName(key);
document.getElementById('theory-scroll').innerHTML = html;
document.getElementById('theory-scroll').innerHTML =
html || '<div class="theory-unavailable">' + t('theoryUnavailable') + '</div>';
showScreen('theory-content');
}
@@ -784,9 +849,6 @@ function getPreferredTheme() {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
const isLightTheme = getPreferredTheme() === 'light';
const TICKER_COLORS = isLightTheme ? TICKER_COLORS_LIGHT : TICKER_COLORS_DARK;
const TICKER_SYSTEMS = [
{ key: 'binary', min: 1, max: 255, suffix: toSubscript(2) },
{ key: 'base3', min: 1, max: 81, suffix: toSubscript(3) },
@@ -800,6 +862,9 @@ const TICKER_SYSTEMS = [
];
function generateTickerText() {
const palette = document.documentElement.dataset.theme === 'light'
? TICKER_COLORS_LIGHT
: TICKER_COLORS_DARK;
const pairs = [];
let lastKey = null;
let lastColor = null;
@@ -812,7 +877,7 @@ function generateTickerText() {
let color;
do {
color = TICKER_COLORS[Math.floor(Math.random() * TICKER_COLORS.length)];
color = palette[Math.floor(Math.random() * palette.length)];
} while (color === lastColor);
lastColor = color;
const num = sys.min + Math.floor(Math.random() * (sys.max - sys.min + 1));
@@ -828,8 +893,10 @@ function generateTickerText() {
return pairs.join('\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0');
}
const tickerText = generateTickerText();
document.getElementById('ticker').innerHTML = tickerText + '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0' + tickerText;
function refreshTicker() {
const tickerText = generateTickerText();
document.getElementById('ticker').innerHTML = tickerText + '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0' + tickerText;
}
const langSelect = document.getElementById('lang-select');
langSelect.value = LANG;
@@ -837,6 +904,7 @@ langSelect.addEventListener('change', () => {
LANG = langSelect.value;
localStorage.setItem('numnum-lang', LANG);
applyTranslations();
refreshDynamicText();
});
applyTranslations();
@@ -844,6 +912,7 @@ applyTranslations();
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
document.getElementById('theme-toggle').textContent = theme === 'dark' ? '\u2600' : '\u263E';
refreshTicker();
}
applyTheme(getPreferredTheme());
@@ -912,7 +981,8 @@ document.getElementById('btn-options-back').addEventListener('click', () => {
});
document.getElementById('btn-play').addEventListener('click', () => {
if (setupState.mode === undefined || setupState.timing === undefined || setupState.rounds === undefined) {
if (!setupState.system || !setupState.rangeData ||
setupState.mode === undefined || setupState.timing === undefined || setupState.rounds === undefined) {
showSetupMessage(t('pleaseSelect'));
return;
}
@@ -978,3 +1048,26 @@ document.addEventListener('keydown', (e) => {
}
}
});
// Handle mobile back/cancel button
const BACK_MAP = {
'screen-setup-system': 'btn-system-back',
'screen-setup-range': 'btn-range-back',
'screen-setup-options': 'btn-options-back',
'screen-game': 'btn-stop',
'screen-results': 'btn-results-menu',
'screen-theory-system': 'btn-theory-back',
'screen-theory-content': 'btn-theory-content-back',
};
window.addEventListener('popstate', () => {
navDepth = Math.max(0, navDepth - 1);
const active = document.querySelector('.screen.active');
if (!active) return;
const btnId = BACK_MAP[active.id];
if (btnId) {
popstateHandled = true;
document.getElementById(btnId).click();
}
});
+3 -3
View File
@@ -10,7 +10,7 @@
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="stylesheet" href="styles.css?v=26">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
@@ -193,10 +193,10 @@
</div>
<script type="module" src="app.js?v=26"></script>
<script type="module" src="app.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js?v=26').catch(() => {});
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
+28 -4
View File
@@ -73,13 +73,31 @@ html[data-theme="light"] {
font-display: swap;
}
@font-face {
font-family: 'Noto Sans Symbols 2';
src: url('NotoSansSymbols2-subset.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
unicode-range: U+232B, U+23F1, U+2600, U+2713, U+2717, U+2801, U+2803, U+2809-280B, U+2811, U+2813, U+2819-281B, U+283C, U+2B95;
}
@font-face {
font-family: 'Noto Sans Symbols 2';
src: url('NotoSansMath-subset.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
unicode-range: U+2295-2296, U+263E;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans Symbols 2', sans-serif;
background: var(--bg);
color: var(--text);
-webkit-tap-highlight-color: transparent;
@@ -189,7 +207,7 @@ body {
white-space: nowrap;
color: var(--text-dim);
font-size: 1.3rem;
font-family: monospace;
font-family: 'Noto Sans Symbols 2', monospace;
animation: ticker-scroll 40s linear infinite;
}
@@ -455,7 +473,7 @@ body {
}
.number-display.mono {
font-family: monospace;
font-family: 'Noto Sans Symbols 2', monospace;
}
.ticker .slavonic-font {
@@ -795,13 +813,19 @@ body {
background: var(--bg-card);
border-radius: 8px;
padding: 16px;
font-family: monospace;
font-family: 'Noto Sans Symbols 2', monospace;
font-size: 1.1rem;
margin: 12px 0;
text-align: center;
letter-spacing: 2px;
}
.theory-unavailable {
text-align: center;
opacity: 0.65;
padding: 24px 0;
}
.theory-scroll .slavonic-font {
font-family: 'Ponomar', serif;
font-size: 1.4rem;
+33 -3
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'numnum-v26';
const CACHE_NAME = 'numnum-v28';
const ASSETS = [
'/',
'/index.html',
@@ -9,11 +9,42 @@ const ASSETS = [
'/icon.svg',
'/icon-192.png',
'/icon-512.png',
'/Ponomar-Regular.woff',
'/NotoSansSymbols2-subset.woff2',
'/NotoSansMath-subset.woff2',
'/theory/base3.en.html',
'/theory/base3.ru.html',
'/theory/binary.en.html',
'/theory/binary.ru.html',
'/theory/braille.en.html',
'/theory/braille.ru.html',
'/theory/greek.en.html',
'/theory/greek.ru.html',
'/theory/hebrew.en.html',
'/theory/hebrew.ru.html',
'/theory/hex.en.html',
'/theory/hex.ru.html',
'/theory/octal.en.html',
'/theory/octal.ru.html',
'/theory/roman.en.html',
'/theory/roman.ru.html',
'/theory/slavonic.en.html',
'/theory/slavonic.ru.html',
'/theory/ternary.en.html',
'/theory/ternary.ru.html',
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
caches.open(CACHE_NAME).then(cache =>
Promise.allSettled(ASSETS.map(url => cache.add(url)))
).then(results => {
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.warn('SW precache failed:', ASSETS[i], r.reason);
}
});
})
);
self.skipWaiting();
});
@@ -28,7 +59,6 @@ self.addEventListener('activate', (e) => {
});
self.addEventListener('fetch', (e) => {
if (e.request.url.includes('/theory/')) return;
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);