diff --git a/Ponomar-Regular.woff b/Ponomar-Regular.woff new file mode 100644 index 0000000..f4f1289 Binary files /dev/null and b/Ponomar-Regular.woff differ diff --git a/app.js b/app.js new file mode 100644 index 0000000..29fd10c --- /dev/null +++ b/app.js @@ -0,0 +1,643 @@ +import { SYSTEMS, generateChoices } from './numerals.js'; + +const SYSTEM_RANGES = { + binary: [ + { label: '2\u2070 \u2013 2\u00B3', min: 1, max: 8 }, + { label: '2\u00B3 \u2013 2\u2077', min: 8, max: 128 }, + { label: '2\u2070 \u2013 2\u2077', min: 1, max: 128 }, + { label: '2\u2070 \u2013 2\u00B9\u2075', min: 1, max: 32768 }, + ], + ternary: [ + { label: '3\u2070 \u2013 3\u00B9', min: 1, max: 3 }, + { label: '\u20133\u00B9 \u2013 3\u00B9', min: -3, max: 3 }, + { label: '\u20133\u00B2 \u2013 3\u00B2', min: -9, max: 9 }, + { label: '\u20133\u00B3 \u2013 3\u00B3', min: -27, max: 27 }, + ], + octal: [ + { label: '8\u2070 \u2013 8\u00B9', min: 1, max: 8 }, + { label: '8\u00B2 \u2013 8\u00B3', min: 64, max: 512 }, + { label: '8\u2070 \u2013 8\u00B3', min: 1, max: 512 }, + { label: '8\u2070 \u2013 8\u2074', min: 1, max: 4096 }, + ], + hex: [ + { label: 'F\u2070 \u2013 F\u00B9', min: 1, max: 16 }, + { label: 'F\u2070 \u2013 F\u00B2', min: 1, max: 256 }, + { label: 'F\u00B2 \u2013 F\u2074', min: 256, max: 65536 }, + { label: 'F\u2070 \u2013 F\u2074', min: 1, max: 65536 }, + ], + braille: [ + { label: '0 \u2013 9', min: 0, max: 9 }, + { label: '10 \u2013 99', min: 10, max: 99 }, + { label: '100 \u2013 999', min: 100, max: 999 }, + { label: '0 \u2013 9999', min: 0, max: 9999 }, + ], + roman: [ + { label: '1 \u2013 9', min: 1, max: 9 }, + { label: '10 \u2013 99', min: 10, max: 99 }, + { label: '100 \u2013 999', min: 100, max: 999 }, + { label: '1000 \u2013 3999', min: 1000, max: 3999 }, + ], + greek: [ + { label: '1 \u2013 9', min: 1, max: 9 }, + { label: '10 \u2013 99', min: 10, max: 99 }, + { label: '100 \u2013 999', min: 100, max: 999 }, + { label: '1000 \u2013 9999', min: 1000, max: 9999 }, + ], + slavonic: [ + { label: '1 \u2013 9', min: 1, max: 9 }, + { label: '10 \u2013 99', min: 10, max: 99 }, + { label: '100 \u2013 999', min: 100, max: 999 }, + { label: '1000 \u2013 9999', min: 1000, max: 9999 }, + ], + hebrew: [ + { label: '1 \u2013 9', min: 1, max: 9 }, + { label: '10 \u2013 99', min: 10, max: 99 }, + { label: '100 \u2013 499', min: 100, max: 499 }, + ], +}; + +const SYSTEM_GROUPS = { + pos: ['binary', 'octal', 'hex', 'ternary', 'braille'], + nonpos: ['roman', 'greek', 'slavonic', 'hebrew'], +}; + +const MODES = [ + { label: 'Choose', value: 'choice' }, + { label: 'Type', value: 'manual' }, +]; + +const TIMINGS = [ + { label: '60 sec', value: 60 }, + { label: '40 sec', value: 40 }, + { label: '10 sec', value: 10 }, + { label: 'No limit', value: 0 }, +]; + +const ROUNDS = [10, 25, 50, 100]; + +let game = { + system: null, + range: null, + mode: null, + timing: null, + rounds: null, + currentRound: 0, + correctCount: 0, + correctAnswer: null, + answered: false, + timerInterval: null, + timeLeft: 0, + mistakes: [], +}; + +function showScreen(id) { + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + document.getElementById('screen-' + id).classList.add('active'); +} + +function shuffle(arr) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function randInt(min, max) { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +// ---- SETUP ---- +let setupState = {}; + +function renderSetupSystem() { + setupState = {}; + const posDiv = document.getElementById('setup-systems-pos'); + const nonposDiv = document.getElementById('setup-systems-nonpos'); + posDiv.innerHTML = ''; + nonposDiv.innerHTML = ''; + + for (const [group, keys] of Object.entries(SYSTEM_GROUPS)) { + const target = group === 'pos' ? posDiv : nonposDiv; + keys.forEach(key => { + const btn = document.createElement('button'); + btn.className = 'option-btn'; + btn.textContent = SYSTEMS[key].name; + btn.addEventListener('click', () => { + posDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected')); + nonposDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + setupState.system = key; + renderSetupRange(); + }); + target.appendChild(btn); + }); + } + + showScreen('setup-system'); +} + +function renderSetupRange() { + const ranges = SYSTEM_RANGES[setupState.system]; + + document.getElementById('range-title').textContent = 'Number Range — ' + SYSTEMS[setupState.system].name; + const rangesDiv = document.getElementById('setup-ranges'); + rangesDiv.innerHTML = ''; + delete setupState.range; + delete setupState.rangeData; + + ranges.forEach((r, i) => { + const btn = document.createElement('button'); + btn.className = 'option-btn'; + btn.textContent = r.label; + btn.addEventListener('click', () => { + rangesDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + setupState.range = i; + setupState.rangeData = r; + renderSetupOptions(); + }); + rangesDiv.appendChild(btn); + }); + + showScreen('setup-range'); +} + +function renderSetupOptions() { + const defaultMode = 0; + const defaultTiming = 3; + const defaultRounds = 0; + + setupState.mode = defaultMode; + setupState.timing = defaultTiming; + setupState.rounds = defaultRounds; + + const modesDiv = document.getElementById('setup-modes'); + modesDiv.innerHTML = ''; + MODES.forEach((m, i) => { + const btn = document.createElement('button'); + btn.className = 'option-btn' + (i === defaultMode ? ' selected' : ''); + btn.textContent = m.label; + btn.addEventListener('click', () => { + modesDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + setupState.mode = i; + }); + modesDiv.appendChild(btn); + }); + + const timingDiv = document.getElementById('setup-timing'); + timingDiv.innerHTML = ''; + TIMINGS.forEach((t, i) => { + const btn = document.createElement('button'); + btn.className = 'option-btn' + (i === defaultTiming ? ' selected' : ''); + btn.textContent = t.label; + btn.addEventListener('click', () => { + timingDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + setupState.timing = i; + }); + timingDiv.appendChild(btn); + }); + + const roundsDiv = document.getElementById('setup-rounds'); + roundsDiv.innerHTML = ''; + ROUNDS.forEach((r, i) => { + const btn = document.createElement('button'); + btn.className = 'option-btn' + (i === defaultRounds ? ' selected' : ''); + btn.textContent = String(r); + btn.addEventListener('click', () => { + roundsDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + setupState.rounds = i; + }); + roundsDiv.appendChild(btn); + }); + + showScreen('setup-options'); +} + +// ---- GAME ---- +function startGame() { + game.system = setupState.system; + game.range = setupState.rangeData || RANGES[setupState.range]; + game.mode = MODES[setupState.mode]?.value ?? setupState.mode; + game.timing = TIMINGS[setupState.timing]?.value ?? setupState.timing; + game.rounds = ROUNDS[setupState.rounds]; + game.currentRound = 0; + game.correctCount = 0; + game.mistakes = []; + + document.getElementById('score-correct').textContent = '0'; + document.getElementById('score-total').textContent = '0'; + document.getElementById('score-system').textContent = SYSTEMS[game.system].name; + + showScreen('game'); + nextRound(); +} + +function nextRound() { + if (game.currentRound >= game.rounds) { + endGame(); + return; + } + + game.currentRound++; + game.answered = false; + game.correctAnswer = randInt(game.range.min, game.range.max); + + document.getElementById('score-total').textContent = game.currentRound; + document.getElementById('score-correct').textContent = game.correctCount; + + const display = SYSTEMS[game.system].toDisplay(game.correctAnswer); + const numEl = document.getElementById('number-display'); + numEl.textContent = display; + numEl.classList.toggle('small-text', display.length > 12); + numEl.classList.toggle('slavonic-font', game.system === 'slavonic'); + + document.getElementById('answer-feedback').textContent = ''; + document.getElementById('answer-feedback').className = 'answer-feedback'; + document.getElementById('btn-next').classList.add('hidden'); + + if (game.mode === 'choice') { + renderChoices(); + } else { + renderNumpad(); + } + + startTimer(); +} + +function renderChoices() { + const choiceArea = document.getElementById('choice-area'); + const numpadArea = document.getElementById('numpad-area'); + choiceArea.classList.remove('hidden'); + numpadArea.classList.add('hidden'); + + const choices = generateChoices(game.correctAnswer, [game.range.min, game.range.max], 3); + choices.forEach((val, i) => { + const btn = document.getElementById('choice-' + i); + btn.textContent = val; + btn.className = 'choice-btn'; + btn.disabled = false; + btn.onclick = () => handleChoice(i, val, choices); + }); +} + +function handleChoice(idx, val, choices) { + if (game.answered) return; + game.answered = true; + stopTimer(); + + const feedback = document.getElementById('answer-feedback'); + const allBtns = choices.map((_, i) => document.getElementById('choice-' + i)); + + allBtns.forEach(b => b.disabled = true); + + if (val === game.correctAnswer) { + game.correctCount++; + document.getElementById('score-correct').textContent = game.correctCount; + allBtns[idx].classList.add('correct-choice'); + feedback.textContent = 'Correct!'; + feedback.className = 'answer-feedback correct'; + } else { + allBtns[idx].classList.add('incorrect-choice'); + const correctIdx = choices.indexOf(game.correctAnswer); + allBtns[correctIdx].classList.add('correct-choice'); + feedback.textContent = `Incorrect — answer: ${game.correctAnswer}`; + feedback.className = 'answer-feedback incorrect'; + game.mistakes.push({ + system: SYSTEMS[game.system].toDisplay(game.correctAnswer), + decimal: game.correctAnswer, + systemName: SYSTEMS[game.system].name, + }); + } + + document.getElementById('btn-next').classList.remove('hidden'); +} + +function renderNumpad() { + const choiceArea = document.getElementById('choice-area'); + const numpadArea = document.getElementById('numpad-area'); + choiceArea.classList.add('hidden'); + numpadArea.classList.remove('hidden'); + + const inputEl = document.getElementById('numpad-input'); + inputEl.textContent = ''; + + const clearBtn = numpadArea.querySelector('[data-key="clear"]'); + if (game.system === 'ternary') { + clearBtn.textContent = '±'; + clearBtn.dataset.key = 'sign'; + } else { + clearBtn.textContent = 'C'; + clearBtn.dataset.key = 'clear'; + } + + document.getElementById('btn-submit-answer').classList.remove('hidden'); + + numpadArea.querySelectorAll('.numpad-key').forEach(key => { + key.onclick = () => handleNumpadKey(key.dataset.key); + }); + + document.getElementById('btn-submit-answer').onclick = () => { + if (game.answered) return; + const val = parseInt(inputEl.textContent, 10); + if (isNaN(val)) return; + game.answered = true; + stopTimer(); + document.getElementById('btn-submit-answer').classList.add('hidden'); + + const feedback = document.getElementById('answer-feedback'); + if (val === game.correctAnswer) { + game.correctCount++; + document.getElementById('score-correct').textContent = game.correctCount; + feedback.textContent = 'Correct!'; + feedback.className = 'answer-feedback correct'; + } else { + feedback.textContent = `Incorrect — answer: ${game.correctAnswer}`; + feedback.className = 'answer-feedback incorrect'; + game.mistakes.push({ + system: SYSTEMS[game.system].toDisplay(game.correctAnswer), + decimal: game.correctAnswer, + systemName: SYSTEMS[game.system].name, + }); + } + + document.getElementById('btn-next').classList.remove('hidden'); + }; +} + +function handleNumpadKey(key) { + const inputEl = document.getElementById('numpad-input'); + if (game.answered) return; + + if (key === 'clear') { + inputEl.textContent = ''; + } else if (key === 'backspace') { + inputEl.textContent = inputEl.textContent.slice(0, -1); + } else if (key === 'sign') { + if (inputEl.textContent.startsWith('-')) { + inputEl.textContent = inputEl.textContent.slice(1); + } else { + inputEl.textContent = '-' + inputEl.textContent; + } + } else { + if (inputEl.textContent.length < 12) { + inputEl.textContent += key; + } + } +} + +function startTimer() { + stopTimer(); + const timerEl = document.getElementById('timer-display'); + const timerArea = document.getElementById('timer-area'); + + if (game.timing === 0) { + timerEl.textContent = ''; + timerEl.classList.remove('urgent'); + timerArea.classList.remove('visible'); + return; + } + + timerArea.classList.add('visible'); + game.timeLeft = game.timing; + timerEl.textContent = game.timeLeft + 's'; + timerEl.classList.remove('urgent'); + + game.timerInterval = setInterval(() => { + game.timeLeft--; + timerEl.textContent = game.timeLeft + 's'; + + if (game.timeLeft <= 10) { + timerEl.classList.add('urgent'); + } + + if (game.timeLeft <= 0) { + stopTimer(); + if (!game.answered) { + game.answered = true; + const feedback = document.getElementById('answer-feedback'); + feedback.textContent = `Time's up! Answer: ${game.correctAnswer}`; + feedback.className = 'answer-feedback incorrect'; + game.mistakes.push({ + system: SYSTEMS[game.system].toDisplay(game.correctAnswer), + decimal: game.correctAnswer, + systemName: SYSTEMS[game.system].name, + }); + + document.getElementById('btn-next').classList.remove('hidden'); + + if (game.mode === 'choice') { + document.querySelectorAll('.choice-btn').forEach(b => b.disabled = true); + } + } + } + }, 1000); +} + +function stopTimer() { + if (game.timerInterval) { + clearInterval(game.timerInterval); + game.timerInterval = null; + } + document.getElementById('timer-display').classList.remove('urgent'); +} + +function endGame() { + stopTimer(); + showScreen('results'); + + const completed = game.currentRound >= game.rounds; + const total = completed ? game.rounds : game.currentRound; + const correct = game.correctCount; + const pct = total > 0 ? Math.round((correct / total) * 100) : 0; + + const summary = document.getElementById('results-summary'); + summary.innerHTML = ` +
${correct}/${total}
+
${pct}% correct
+ `; + + const congratsEl = document.getElementById('results-congrats'); + const labelEl = document.getElementById('results-mistakes-label'); + const listEl = document.getElementById('results-list'); + congratsEl.innerHTML = ''; + labelEl.textContent = 'Mistakes'; + labelEl.style.display = ''; + listEl.innerHTML = ''; + + if (game.mistakes.length > 0) { + game.mistakes.forEach(m => { + const item = document.createElement('div'); + item.className = 'result-item'; + item.innerHTML = ` +
${m.system}
+
\u2192
+
${m.decimal}
+ `; + listEl.appendChild(item); + }); + } else if (completed) { + labelEl.style.display = 'none'; + congratsEl.textContent = 'Congratulations!'; + spawnConfetti(); + } else { + labelEl.style.display = 'none'; + } +} + +function spawnConfetti() { + const chars = ['🎉', '🎊', '✨', '🎈', '🥳', '💫']; + const container = document.getElementById('results-congrats'); + + for (let i = 0; i < 3; i++) { + setTimeout(() => { + const burst = document.createElement('div'); + burst.className = 'confetti-burst'; + burst.style.left = 15 + Math.random() * 70 + '%'; + burst.style.top = 20 + Math.random() * 40 + '%'; + + for (let j = 0; j < 12; j++) { + const particle = document.createElement('span'); + particle.className = 'confetti-particle'; + particle.textContent = chars[Math.floor(Math.random() * chars.length)]; + particle.style.setProperty('--dx', (Math.random() - 0.5) * 120 + 'px'); + particle.style.setProperty('--dy', -(40 + Math.random() * 80) + 'px'); + particle.style.setProperty('--rot', Math.random() * 360 + 'deg'); + particle.style.animationDuration = (0.6 + Math.random() * 0.4) + 's'; + burst.appendChild(particle); + } + + container.appendChild(burst); + setTimeout(() => burst.remove(), 1200); + }, i * 500); + } +} + +// ---- INIT ---- + +const SUBSCRIPTS = { '0':'\u2080', '1':'\u2081', '2':'\u2082', '3':'\u2083', + '4':'\u2084', '5':'\u2085', '6':'\u2086', '7':'\u2087', '8':'\u2088', '9':'\u2089' }; + +function toSubscript(n) { + return String(n).split('').map(d => SUBSCRIPTS[d]).join(''); +} + +const TICKER_SYSTEMS = [ + { key: 'binary', min: 1, max: 255, prefix: toSubscript(2) }, + { key: 'octal', min: 1, max: 511, prefix: toSubscript(8) }, + { key: 'hex', min: 1, max: 255, prefix: '0x' }, + { key: 'ternary', min: 1, max: 27, prefix: toSubscript(3) }, + { key: 'braille', min: 1, max: 99, prefix: null }, + { key: 'roman', min: 1, max: 3999, prefix: null }, + { key: 'greek', min: 1, max: 999, prefix: null }, + { key: 'slavonic', min: 1, max: 999, prefix: null }, +]; + +function generateTickerText() { + const pairs = []; + for (let i = 0; i < 20; i++) { + const sys = TICKER_SYSTEMS[Math.floor(Math.random() * TICKER_SYSTEMS.length)]; + const num = sys.min + Math.floor(Math.random() * (sys.max - sys.min + 1)); + const display = SYSTEMS[sys.key].toDisplay(num); + const prefixed = sys.prefix ? sys.prefix + display : display; + const wrapped = sys.key === 'slavonic' + ? '' + prefixed + '' + : prefixed; + pairs.push(wrapped + ' = ' + num); + } + 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 showSetupMessage(msg) { + const containers = document.querySelectorAll('.setup-container'); + let el = document.getElementById('setup-message'); + if (!el) { + el = document.createElement('div'); + el.id = 'setup-message'; + el.className = 'setup-message'; + } + containers.forEach(c => { + if (!c.contains(el)) c.prepend(el); + }); + el.textContent = msg; + el.classList.add('visible'); + setTimeout(() => el.classList.remove('visible'), 2500); +} + +document.getElementById('btn-start').addEventListener('click', () => { + renderSetupSystem(); +}); + +document.getElementById('btn-system-back').addEventListener('click', () => { + showScreen('menu'); +}); + +document.getElementById('btn-range-back').addEventListener('click', () => { + renderSetupSystem(); +}); + +document.getElementById('btn-options-back').addEventListener('click', () => { + renderSetupRange(); +}); + +document.getElementById('btn-play').addEventListener('click', () => { + if (setupState.mode === undefined || setupState.timing === undefined || setupState.rounds === undefined) { + showSetupMessage('Please select all options'); + return; + } + startGame(); +}); + +document.getElementById('btn-next').addEventListener('click', () => { + nextRound(); +}); + +document.getElementById('btn-play-again').addEventListener('click', () => { + renderSetupSystem(); +}); + +document.getElementById('btn-results-menu').addEventListener('click', () => { + showScreen('menu'); +}); + +document.getElementById('btn-stop').addEventListener('click', () => { + stopTimer(); + endGame(); +}); + +// Keyboard support for numpad +document.addEventListener('keydown', (e) => { + if (!document.getElementById('screen-game').classList.contains('active')) return; + if (game.answered) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + nextRound(); + } + return; + } + + if (game.mode === 'manual') { + if (e.key >= '0' && e.key <= '9') { + handleNumpadKey(e.key); + } else if (e.key === 'Backspace') { + handleNumpadKey('backspace'); + } else if (e.key === 'Escape') { + handleNumpadKey('clear'); + } else if (e.key === 'Enter') { + document.getElementById('btn-submit-answer').click(); + } + } else if (game.mode === 'choice') { + if (e.key === '1' || e.key === '2' || e.key === '3') { + const idx = parseInt(e.key) - 1; + const btn = document.getElementById('choice-' + idx); + if (btn && !btn.disabled) btn.click(); + } + } +}); diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..ec5a81e --- /dev/null +++ b/icon.svg @@ -0,0 +1,5 @@ + + + Num + Num + diff --git a/index.html b/index.html new file mode 100644 index 0000000..10f17d3 --- /dev/null +++ b/index.html @@ -0,0 +1,160 @@ + + + + + + + + + NumNum + + + + + + +
+ + +
+ +
+ + +
+
+

Choose numeral system

+ +
+

Positional

+
+
+ +
+

Non-Positional

+
+
+ + +
+
+ + +
+
+

Number Range

+ +
+
+
+ + +
+
+ + +
+
+

Difficulty level

+ +
+

Answer Mode

+
+
+ +
+

Time Limit

+
+
+ +
+

Number of Rounds

+
+
+ + + +
+
+ + +
+
+
+ 0/0 + +
+
+ Time left +
+
+
+ +
+
+ +
+
+
+ + + + + + + + +
+
+ + +
+
+

Results

+
+
+

Mistakes

+
+ + +
+
+ +
+ + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..75da91c --- /dev/null +++ b/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "NumNum", + "short_name": "NumNum", + "description": "Train your numeric systems", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/numerals.js b/numerals.js new file mode 100644 index 0000000..5c57c25 --- /dev/null +++ b/numerals.js @@ -0,0 +1,221 @@ +export const SYSTEMS = { + binary: { + name: 'Binary', + short: 'BIN', + toDisplay: (n) => n.toString(2), + }, + octal: { + name: 'Octal', + short: 'OCT', + toDisplay: (n) => n.toString(8), + }, + hex: { + name: 'Hexadecimal', + short: 'HEX', + toDisplay: (n) => n.toString(16).toUpperCase(), + }, + ternary: { + name: 'Balanced Ternary', + short: 'BT3', + toDisplay: toBalTernary, + }, + braille: { + name: 'Braille Decimal', + short: 'BRL', + toDisplay: toBraille, + }, + roman: { + name: 'Roman', + short: 'ROM', + toDisplay: toRoman, + }, + slavonic: { + name: 'Church Slavonic', + short: 'SLV', + toDisplay: toSlavonic, + }, + greek: { + name: 'Greek', + short: 'GRK', + toDisplay: toGreek, + }, + hebrew: { + name: 'Hebrew', + short: 'HEB', + toDisplay: toHebrew, + }, +}; + +function toRoman(num) { + if (num <= 0 || num > 3999) return String(num); + const vals = [1000,900,500,400,100,90,50,40,10,9,5,4,1]; + const syms = ['M','CM','D','CD','C','XC','L','XL','X','IX','V','IV','I']; + let result = ''; + for (let i = 0; i < vals.length; i++) { + while (num >= vals[i]) { + result += syms[i]; + num -= vals[i]; + } + } + return result; +} + +function toBalTernary(num) { + if (num === 0) return '0'; + + let sign = 1; + let n = num; + if (n < 0) { + sign = -1; + n = -n; + } + + const digits = []; + while (n > 0) { + let rem = n % 3; + n = Math.floor(n / 3); + if (rem === 2) { + digits.unshift('\u2296'); + n += 1; + } else if (rem === 1) { + digits.unshift('\u2295'); + } else { + digits.unshift('0'); + } + } + + if (sign === -1) { + return digits.map(d => { + if (d === '\u2295') return '\u2296'; + if (d === '\u2296') return '\u2295'; + return d; + }).join(''); + } + + return digits.join(''); +} + +const BRAILLE_NUMS = ['\u281A','\u2801','\u2803','\u2809','\u2819','\u2811','\u280B','\u281B','\u2813','\u280A']; +const BRAILLE_HASH = '\u283C'; + +function toBraille(num) { + if (num < 0) return String(num); + if (num === 0) return BRAILLE_HASH + BRAILLE_NUMS[0]; + return BRAILLE_HASH + String(num).split('').map(d => BRAILLE_NUMS[parseInt(d)]).join(''); +} + +const GRK_UNITS = ['\u03B1','\u03B2','\u03B3','\u03B4','\u03B5','\u03DB','\u03B6','\u03B7','\u03B8']; +const GRK_TENS = ['\u03B9','\u03BA','\u03BB','\u03BC','\u03BD','\u03BE','\u03BF','\u03C0','\u03DF']; +const GRK_HUNDREDS = ['\u03C1','\u03C3','\u03C4','\u03C5','\u03C6','\u03C7','\u03C8','\u03C9','\u03E1']; +const GRK_THOU = '\u0375'; +const GRK_KER = '\u1FFD'; + +function toGreek(num) { + if (num <= 0 || num > 9999) return String(num); + let result = ''; + const thou = Math.floor(num / 1000); + num %= 1000; + const h = Math.floor(num / 100); + num %= 100; + const t = Math.floor(num / 10); + const u = num % 10; + if (thou > 0) result += GRK_THOU + GRK_UNITS[thou - 1]; + if (h > 0) result += GRK_HUNDREDS[h - 1]; + if (t > 0) result += GRK_TENS[t - 1]; + if (u > 0) result += GRK_UNITS[u - 1]; + return result ? result + GRK_KER : '0'; +} + +const SLV_UNITS = ['а','в','г','д','є','ѕ','з','и','ѳ']; +const SLV_TENS = ['і','к','л','м','н','ѯ','ѻ','п','ч']; +const SLV_HUNDREDS = ['р','с','т','у','ф','х','ѱ','ѿ','ц']; +const SLV_THOU = '\u0482'; +const SLV_TITLO = '\u0483'; + +function toSlavonic(num) { + if (num <= 0 || num > 9999) return String(num); + + const groups = []; + const thou = Math.floor(num / 1000); + num %= 1000; + const h = Math.floor(num / 100); + num %= 100; + const t = Math.floor(num / 10); + const u = num % 10; + + if (thou > 0) groups.push({ p: SLV_THOU, l: SLV_UNITS[thou - 1] }); + + if (h > 0) groups.push({ p: '', l: SLV_HUNDREDS[h - 1] }); + + const twoDigit = t * 10 + u; + if (twoDigit >= 11 && twoDigit <= 19) { + if (u > 0) groups.push({ p: '', l: SLV_UNITS[u - 1] }); + if (t > 0) groups.push({ p: '', l: SLV_TENS[t - 1] }); + } else { + if (t > 0) groups.push({ p: '', l: SLV_TENS[t - 1] }); + if (u > 0) groups.push({ p: '', l: SLV_UNITS[u - 1] }); + } + + if (groups.length === 0) return '0'; + + const titloIdx = groups.length >= 2 ? groups.length - 2 : 0; + groups[titloIdx].l += SLV_TITLO; + + return groups.map(g => g.p + g.l).join('') + '.'; +} + +const HEB_UNITS = ['\u05D0','\u05D1','\u05D2','\u05D3','\u05D4','\u05D5','\u05D6','\u05D7','\u05D8']; +const HEB_TENS = ['\u05D9','\u05DB','\u05DC','\u05DE','\u05E0','\u05E1','\u05E2','\u05E4','\u05E6']; +const HEB_HUNDREDS = ['\u05E7','\u05E8','\u05E9','\u05EA']; +const HEB_GERESH = '\u05F3'; +const HEB_GERSHAYIM = '\u05F4'; + +function toHebrew(num) { + if (num <= 0 || num > 499) return String(num); + + if (num === 15) return '\u05D8' + HEB_GERSHAYIM + '\u05D5'; + if (num === 16) return '\u05D8' + HEB_GERSHAYIM + '\u05D6'; + + const letters = []; + const h = Math.floor(num / 100); + num %= 100; + const t = Math.floor(num / 10); + const u = num % 10; + + if (h > 0) letters.push(HEB_HUNDREDS[h - 1]); + if (t > 0) letters.push(HEB_TENS[t - 1]); + if (u > 0) letters.push(HEB_UNITS[u - 1]); + + if (letters.length === 0) return '0'; + if (letters.length === 1) return letters[0] + HEB_GERESH; + return letters.slice(0, -1).join('') + HEB_GERSHAYIM + letters[letters.length - 1]; +} + +export function generateChoices(correctAnswer, allRanges, count = 3) { + const choices = new Set([correctAnswer]); + const [min, max] = allRanges; + let attempts = 0; + while (choices.size < count && attempts < 100) { + let wrong; + const offset = Math.floor(Math.random() * 20) + 1; + if (Math.random() < 0.5) { + wrong = correctAnswer + offset; + } else { + wrong = correctAnswer - offset; + } + if (wrong >= min && wrong <= max && wrong !== correctAnswer) { + choices.add(wrong); + } + attempts++; + } + while (choices.size < count) { + const r = min + Math.floor(Math.random() * (max - min + 1)); + choices.add(r); + } + const arr = Array.from(choices); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..37066e6 --- /dev/null +++ b/styles.css @@ -0,0 +1,616 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #1a1a2e; + --bg-light: #16213e; + --bg-card: #0f3460; + --accent: #e94560; + --accent-hover: #ff6b81; + --text: #eaeaea; + --text-dim: #a0a0b0; + --correct: #2ecc71; + --incorrect: #e74c3c; + --border-radius: 12px; + --safe-top: env(safe-area-inset-top); + --safe-bottom: env(safe-area-inset-bottom); +} + +@font-face { + font-family: 'Ponomar'; + src: url('Ponomar-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +html, body { + height: 100%; + overflow: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-tap-highlight-color: transparent; + user-select: none; + -webkit-user-select: none; +} + +#app { + height: 100%; + padding-top: var(--safe-top); + padding-bottom: var(--safe-bottom); +} + +.screen { + display: none; + height: 100%; + overflow-y: auto; +} + +.screen.active { + display: flex; + flex-direction: column; +} + +/* MENU */ +.menu-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 32px; +} + +.logo { + font-size: 3.5rem; + font-weight: 800; + background: linear-gradient(135deg, var(--accent), #ff9a56); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 8px; +} + +.subtitle { + color: var(--text-dim); + font-size: 1.1rem; + margin-bottom: 16px; +} + +.ticker-wrap { + overflow: hidden; + width: 100%; + margin-bottom: 32px; +} + +.ticker { + display: inline-block; + white-space: nowrap; + color: var(--text-dim); + font-size: 1.05rem; + font-family: monospace; + animation: ticker-scroll 40s linear infinite; + padding-left: 100%; +} + +@keyframes ticker-scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +/* BUTTONS */ +.btn { + display: block; + width: 100%; + padding: 14px 24px; + border: none; + border-radius: var(--border-radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.1s, opacity 0.1s; + text-align: center; +} + +.btn:active { + transform: scale(0.97); + opacity: 0.9; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text); +} + +.btn-large { + padding: 18px 32px; + font-size: 1.2rem; + max-width: 320px; + margin: 0 auto; +} + +/* SETUP */ +.setup-container { + padding: 24px; + max-width: 480px; + margin: 0 auto; + width: 100%; + padding-bottom: 100px; +} + +.setup-container h2 { + margin-bottom: 20px; +} + +.setup-section { + margin-bottom: 28px; +} + +.setup-section h3 { + color: var(--text-dim); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 12px; +} + +.system-group { + margin-bottom: 28px; +} + +.system-group h3 { + color: var(--text-dim); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 12px; +} + +.option-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.option-btn { + padding: 14px 12px; + background: var(--bg-light); + border: 2px solid transparent; + border-radius: 8px; + color: var(--text); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + text-align: center; + transition: border-color 0.15s, background 0.15s; +} + +.option-btn.selected { + border-color: var(--accent); + background: rgba(233, 69, 96, 0.15); +} + +.option-btn:active { + transform: scale(0.97); +} + +#btn-play { + margin-top: 16px; + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +#btn-options-back { + margin-top: 12px; +} + +#btn-setup-back, #btn-config-back { + margin-top: 12px; + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +/* GAME */ +.game-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--bg-light); + flex-shrink: 0; + position: relative; +} + +.score-display { + font-size: 1.2rem; + font-weight: 700; +} + +#score-system { + margin-left: 8px; + color: var(--text-dim); + font-size: 0.9rem; +} + +.timer-area { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 6px; + visibility: hidden; +} + +.timer-area.visible { + visibility: visible; +} + +.timer-label { + color: var(--text-dim); + font-size: 0.85rem; +} + +.timer-display { + font-size: 1.3rem; + font-weight: 700; + color: var(--accent); + min-width: 60px; + text-align: right; +} + +.header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.btn-stop { + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: var(--bg-card); + color: var(--text-dim); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.btn-stop:active { + background: var(--accent); + color: white; +} + +.timer-display.urgent { + color: var(--incorrect); + animation: pulse 0.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.game-body { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 20px; + overflow-y: auto; +} + +.number-display { + font-size: 2.8rem; + font-weight: 800; + text-align: center; + padding: 32px 20px; + margin: 16px 0; + background: var(--bg-card); + border-radius: var(--border-radius); + width: 100%; + max-width: 400px; + word-break: break-all; + line-height: 1.3; + min-height: 100px; + display: flex; + align-items: center; + justify-content: center; +} + +.number-display.small-text { + font-size: 1.6rem; +} + +.slavonic-font { + font-family: 'Ponomar', serif; +} + +.number-display.slavonic-font { + font-size: 3.2rem; +} + +.answer-feedback { + min-height: 40px; + font-size: 1.1rem; + font-weight: 600; + text-align: center; + margin: 8px 0; + transition: opacity 0.2s; +} + +.answer-feedback.correct { + color: var(--correct); +} + +.answer-feedback.incorrect { + color: var(--incorrect); +} + +/* CHOICES */ +.choice-area { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 400px; + margin-top: 16px; +} + +.choice-btn { + padding: 18px 20px; + background: var(--bg-card); + border: 2px solid transparent; + border-radius: var(--border-radius); + color: var(--text); + font-size: 1.3rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.choice-btn:active { + transform: scale(0.98); +} + +.choice-btn.correct-choice { + border-color: var(--correct); + background: rgba(46, 204, 113, 0.15); +} + +.choice-btn.incorrect-choice { + border-color: var(--incorrect); + background: rgba(231, 76, 60, 0.15); +} + +.choice-btn:disabled { + opacity: 0.7; + cursor: default; +} + +/* NUMPAD */ +.numpad-area { + width: 100%; + max-width: 360px; + margin-top: 16px; +} + +.numpad-input { + font-size: 2rem; + font-weight: 700; + text-align: center; + padding: 16px; + background: var(--bg-card); + border-radius: 8px; + min-height: 60px; + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: center; + letter-spacing: 2px; + overflow: hidden; +} + +.numpad { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 16px; +} + +.numpad-key { + padding: 18px; + background: var(--bg-light); + border: none; + border-radius: 8px; + color: var(--text); + font-size: 1.4rem; + font-weight: 600; + cursor: pointer; + transition: background 0.1s; +} + +.numpad-key:active { + background: var(--bg-card); +} + +.numpad-action { + background: var(--bg-card); + color: var(--text-dim); +} + +#btn-submit-answer { + max-width: 320px; + margin: 0 auto; +} + +.hidden { + display: none !important; +} + +#btn-next { + margin-top: 16px; + max-width: 320px; +} + +/* RESULTS */ +.results-container { + padding: 24px; + max-width: 480px; + margin: 0 auto; + width: 100%; + padding-bottom: 100px; + position: relative; +} + +.results-container h2 { + margin-bottom: 16px; +} + +.results-summary { + font-size: 1.2rem; + margin-bottom: 24px; + padding: 20px; + background: var(--bg-card); + border-radius: var(--border-radius); + text-align: center; +} + +.results-summary .big-number { + font-size: 2.5rem; + font-weight: 800; + color: var(--accent); +} + +.results-congrats { + text-align: center; + font-size: 1.5rem; + font-weight: 700; + color: var(--correct); + margin-bottom: 16px; + min-height: 48px; +} + +.results-mistakes-label { + color: var(--text-dim); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 12px; +} + +.results-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 24px; +} + +.result-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-light); + border-radius: 8px; + border-left: 4px solid var(--incorrect); +} + +.result-system { + font-size: 1.4rem; + font-weight: 700; + flex-shrink: 0; + min-width: 80px; + word-break: break-all; +} + +.result-arrow { + color: var(--text-dim); + flex-shrink: 0; +} + +.result-decimal { + font-size: 1.2rem; + font-weight: 600; + color: var(--correct); +} + +.no-mistakes { + text-align: center; + color: var(--correct); + font-size: 1.2rem; + padding: 32px; +} + +#btn-play-again { + max-width: 320px; + margin: 0 auto 12px; +} + +#btn-results-menu { + max-width: 320px; + margin: 0 auto; +} + +.setup-message { + display: none; + background: var(--accent); + color: white; + padding: 14px 20px; + border-radius: var(--border-radius); + font-weight: 600; + text-align: center; + margin-bottom: 16px; + animation: fadeIn 0.2s; +} + +.setup-message.visible { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +.confetti-burst { + position: absolute; + pointer-events: none; +} + +.confetti-particle { + position: absolute; + font-size: 1.4rem; + animation: confetti-fly 0.8s ease-out forwards; +} + +@keyframes confetti-fly { + 0% { + transform: translate(0, 0) rotate(0deg); + opacity: 1; + } + 100% { + transform: translate(var(--dx), var(--dy)) rotate(var(--rot)); + opacity: 0; + } +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..83ac76c --- /dev/null +++ b/sw.js @@ -0,0 +1,32 @@ +const CACHE_NAME = 'numnum-v30'; +const ASSETS = [ + '/', + '/index.html', + '/styles.css', + '/app.js', + '/numerals.js', + '/manifest.json', + '/icon.svg', +]; + +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (e) => { + e.respondWith( + caches.match(e.request).then(r => r || fetch(e.request)) + ); +});