Skip to content

Commit cb06bc2

Browse files
author
web3blind
committed
Add narrator voice preferences
1 parent 2dfac1b commit cb06bc2

8 files changed

Lines changed: 144 additions & 15 deletions

File tree

app/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@
120120
<script src="js/config.js?v=20260712a"></script>
121121

122122
<!-- I18n -->
123-
<script src="js/i18n/ru.js?v=20260713b"></script>
124-
<script src="js/i18n/en.js?v=20260713b"></script>
123+
<script src="js/i18n/ru.js?v=20260713c"></script>
124+
<script src="js/i18n/en.js?v=20260713c"></script>
125125

126126
<!-- Utilities -->
127127
<script src="js/utils/helpers.js?v=20260621l"></script>
@@ -180,7 +180,7 @@
180180
<script src="js/ui/components/toast.js?v=20260711b"></script>
181181
<script src="js/ui/components/modal.js"></script>
182182
<script src="js/ui/components/nav.js?v=20260712b"></script>
183-
<script src="js/ui/components/battle-narrator.js?v=20260712b"></script>
183+
<script src="js/ui/components/battle-narrator.js?v=20260713a"></script>
184184

185185
<!-- UI Screens -->
186186
<script src="js/ui/screens/landing.js"></script>
@@ -199,7 +199,7 @@
199199
<script src="js/ui/screens/crafting.js?v=20260621j"></script>
200200
<script src="js/ui/screens/quests.js?v=20260712a"></script>
201201
<script src="js/ui/screens/world-boss.js"></script>
202-
<script src="js/ui/screens/settings.js?v=20260712b"></script>
202+
<script src="js/ui/screens/settings.js?v=20260713a"></script>
203203
<script src="js/ui/screens/help.js?v=20260712a"></script>
204204
<script src="js/ui/screens/leaderboard.js?v=20260712c"></script>
205205
<script src="js/ui/screens/temple.js?v=20260712c"></script>

app/js/i18n/en.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,15 @@ var LangEN = {
355355
narrator_label: 'Battle Narrator',
356356
narrator_toggle: 'Battle Narrator',
357357
narrator_test: 'Test narrator',
358-
narrator_test_message: 'Battle Narrator is online. If your screen reader is enabled, it should read this message.',
358+
narrator_test_message: 'Battle Narrator is online. This is the selected voice for hunts and duels.',
359+
narrator_voice_gender: 'Narrator voice',
360+
narrator_voice_male: 'Male',
361+
narrator_voice_female: 'Female',
362+
narrator_voice_timbre: 'Narrator timbre',
363+
narrator_timbre_rough: 'Rough and low',
364+
narrator_timbre_neutral: 'Neutral',
365+
narrator_timbre_soft: 'Soft',
366+
narrator_voice_hint: 'Default is a low male voice. Available voices depend on the browser and system; if the exact voice is missing, the game still adjusts pitch and speech rate.',
359367
narrator_pve_attack: 'You used {spell}. {creature} takes {damage} damage.',
360368
narrator_critical: 'Critical hit!',
361369
narrator_hunt_victory: 'Victory! {creature} defeated. {xp} XP gained.',

app/js/i18n/ru.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,15 @@ var LangRU = {
355355
narrator_label: 'Боевой Рассказчик',
356356
narrator_toggle: 'Боевой Рассказчик',
357357
narrator_test: 'Проверить рассказчика',
358-
narrator_test_message: 'Боевой рассказчик на связи. Если включён экранный диктор, он должен прочитать это сообщение.',
358+
narrator_test_message: 'Боевой рассказчик на связи. Сейчас прозвучит выбранный голос для охоты и дуэлей.',
359+
narrator_voice_gender: 'Голос рассказчика',
360+
narrator_voice_male: 'Мужской',
361+
narrator_voice_female: 'Женский',
362+
narrator_voice_timbre: 'Тембр рассказчика',
363+
narrator_timbre_rough: 'Хриплый и низкий',
364+
narrator_timbre_neutral: 'Нейтральный',
365+
narrator_timbre_soft: 'Мягкий',
366+
narrator_voice_hint: 'По умолчанию выбран мужской низкий голос. Доступные голоса зависят от браузера и системы; если нужного голоса нет, игра всё равно меняет высоту и темп речи.',
359367
narrator_pve_attack: 'Ты использовал {spell}. {creature} получает {damage} урона.',
360368
narrator_critical: 'Критический удар!',
361369
narrator_hunt_victory: 'Победа! {creature} повержен. Получено {xp} опыта.',

app/js/ui/components/battle-narrator.js

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ var BattleNarrator = (function() {
88

99
var enabled = false;
1010
var narratorEl = null;
11+
var voiceGender = 'male';
12+
var voiceTimbre = 'rough';
1113

1214
/**
1315
* Initialize the Battle Narrator.
@@ -17,6 +19,8 @@ var BattleNarrator = (function() {
1719
// Check saved preference
1820
var saved = localStorage.getItem(VizMagicConfig.STORAGE_PREFIX + 'battle_narrator');
1921
enabled = saved === 'true' || saved === '1';
22+
voiceGender = _getStoredVoiceOption('narrator_voice_gender', 'male');
23+
voiceTimbre = _getStoredVoiceOption('narrator_voice_timbre', 'rough');
2024

2125
// Also auto-enable if screen reader likely active
2226
if (A11y.likelyScreenReader()) {
@@ -58,6 +62,48 @@ var BattleNarrator = (function() {
5862
return enabled;
5963
}
6064

65+
function _getStoredVoiceOption(key, fallback) {
66+
try {
67+
var value = localStorage.getItem(VizMagicConfig.STORAGE_PREFIX + key);
68+
return value || fallback;
69+
} catch (e) {}
70+
return fallback;
71+
}
72+
73+
function setVoiceOptions(gender, timbre) {
74+
voiceGender = gender || 'male';
75+
voiceTimbre = timbre || 'rough';
76+
try {
77+
localStorage.setItem(VizMagicConfig.STORAGE_PREFIX + 'narrator_voice_gender', voiceGender);
78+
localStorage.setItem(VizMagicConfig.STORAGE_PREFIX + 'narrator_voice_timbre', voiceTimbre);
79+
} catch (e) {}
80+
}
81+
82+
function getVoiceOptions() {
83+
return { gender: voiceGender, timbre: voiceTimbre };
84+
}
85+
86+
function _selectVoice(lang) {
87+
if (typeof window === 'undefined' || !window.speechSynthesis || !window.speechSynthesis.getVoices) return null;
88+
var voices = window.speechSynthesis.getVoices() || [];
89+
if (!voices.length) return null;
90+
var langPrefix = (lang || 'ru-RU').split('-')[0].toLowerCase();
91+
var localVoices = [];
92+
for (var i = 0; i < voices.length; i++) {
93+
var v = voices[i];
94+
if (v && v.lang && v.lang.toLowerCase().indexOf(langPrefix) === 0) localVoices.push(v);
95+
}
96+
var pool = localVoices.length ? localVoices : voices;
97+
var maleHints = /male|муж|alex|alexander|maxim|pavel|dmitry|nikolai|yuri|george|daniel|denis|sergey|anton/i;
98+
var femaleHints = /female|жен|anna|elena|irina|maria|oksana|svetlana|google русский|milena|alice/i;
99+
var hints = voiceGender === 'female' ? femaleHints : maleHints;
100+
for (var j = 0; j < pool.length; j++) {
101+
var name = (pool[j].name || '') + ' ' + (pool[j].voiceURI || '');
102+
if (hints.test(name)) return pool[j];
103+
}
104+
return pool[0] || null;
105+
}
106+
61107
/**
62108
* Announce a message through the narrator.
63109
* @param {string} message
@@ -86,9 +132,11 @@ var BattleNarrator = (function() {
86132
try {
87133
var utterance = new SpeechSynthesisUtterance(message);
88134
var lang = (Helpers.getCurrentLang && Helpers.getCurrentLang() === 'en') ? 'en-US' : 'ru-RU';
135+
var voice = _selectVoice(lang);
89136
utterance.lang = lang;
90-
utterance.rate = 1;
91-
utterance.pitch = 1;
137+
if (voice) utterance.voice = voice;
138+
utterance.rate = (voiceTimbre === 'rough') ? 0.88 : (voiceTimbre === 'soft' ? 0.96 : 1);
139+
utterance.pitch = (voiceGender === 'male') ? (voiceTimbre === 'rough' ? 0.65 : 0.82) : (voiceTimbre === 'rough' ? 0.9 : 1.08);
92140
utterance.volume = 1;
93141
window.speechSynthesis.cancel();
94142
window.speechSynthesis.speak(utterance);
@@ -246,6 +294,8 @@ var BattleNarrator = (function() {
246294
init: init,
247295
setEnabled: setEnabled,
248296
isEnabled: isEnabled,
297+
setVoiceOptions: setVoiceOptions,
298+
getVoiceOptions: getVoiceOptions,
249299
announce: announce,
250300
spatialHint: spatialHint,
251301
announcePvEAttack: announcePvEAttack,

app/js/ui/screens/settings.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ var SettingsScreen = (function() {
2727
return fallback;
2828
}
2929

30+
function _getStoredText(key, fallback) {
31+
try {
32+
var value = localStorage.getItem(STORAGE_PREFIX + key);
33+
if (value !== null && value !== '') return value;
34+
} catch (e) {}
35+
return fallback;
36+
}
37+
3038
function _setStoredBool(key, value) {
3139
try {
3240
localStorage.setItem(STORAGE_PREFIX + key, value ? '1' : '0');
@@ -51,6 +59,10 @@ var SettingsScreen = (function() {
5159
var sfxVolume = Math.round(_getStoredNumber('sfx_volume', 0.5) * 100);
5260
var musicVolume = Math.round(_getStoredNumber('music_volume', 0.5) * 100);
5361
var narratorEnabled = (typeof BattleNarrator !== 'undefined' && BattleNarrator.isEnabled) ? BattleNarrator.isEnabled() : _getStoredBool('battle_narrator', false);
62+
var narratorVoice = (typeof BattleNarrator !== 'undefined' && BattleNarrator.getVoiceOptions) ? BattleNarrator.getVoiceOptions() : {
63+
gender: _getStoredText('narrator_voice_gender', 'male'),
64+
timbre: _getStoredText('narrator_voice_timbre', 'rough')
65+
};
5466
if (typeof SoundManager !== 'undefined') SoundManager.setVolume(sfxVolume / 100);
5567

5668
el.innerHTML =
@@ -72,6 +84,16 @@ var SettingsScreen = (function() {
7284
_renderSlider('sfx-volume', t('settings_sfx'), sfxVolume) +
7385
_renderSlider('music-volume', t('settings_music'), musicVolume) +
7486
_renderToggle('narrator-toggle', t('narrator_toggle'), narratorEnabled) +
87+
_renderSelect('narrator-voice-gender', t('narrator_voice_gender'), [
88+
{ value: 'male', label: t('narrator_voice_male') },
89+
{ value: 'female', label: t('narrator_voice_female') }
90+
], narratorVoice.gender || 'male') +
91+
_renderSelect('narrator-voice-timbre', t('narrator_voice_timbre'), [
92+
{ value: 'rough', label: t('narrator_timbre_rough') },
93+
{ value: 'neutral', label: t('narrator_timbre_neutral') },
94+
{ value: 'soft', label: t('narrator_timbre_soft') }
95+
], narratorVoice.timbre || 'rough') +
96+
'<p class="settings-help-text">' + t('narrator_voice_hint') + '</p>' +
7597
'<button type="button" class="btn btn-secondary btn-sm" id="btn-test-narrator">' + t('narrator_test') + '</button>' +
7698
'<div class="settings-field">' +
7799
'<label for="sound-density" class="input-label">' + t('settings_sound_density') + '</label>' +
@@ -149,6 +171,16 @@ var SettingsScreen = (function() {
149171
'</div>';
150172
}
151173

174+
function _renderSelect(id, label, options, selectedValue) {
175+
var html = '<div class="settings-field">' +
176+
'<label for="' + id + '" class="input-label">' + label + '</label>' +
177+
'<select id="' + id + '" class="input-field" aria-label="' + label + '">';
178+
for (var i = 0; i < options.length; i++) {
179+
html += '<option value="' + options[i].value + '"' + (options[i].value === selectedValue ? ' selected' : '') + '>' + options[i].label + '</option>';
180+
}
181+
return html + '</select></div>';
182+
}
183+
152184
function _bindEvents(el) {
153185
// Language toggles
154186
var langRu = el.querySelector('#lang-ru');
@@ -211,11 +243,27 @@ var SettingsScreen = (function() {
211243
});
212244
}
213245

246+
var narratorGender = el.querySelector('#narrator-voice-gender');
247+
var narratorTimbre = el.querySelector('#narrator-voice-timbre');
248+
function updateNarratorVoice() {
249+
if (typeof BattleNarrator !== 'undefined' && BattleNarrator.setVoiceOptions) {
250+
BattleNarrator.setVoiceOptions(narratorGender ? narratorGender.value : 'male', narratorTimbre ? narratorTimbre.value : 'rough');
251+
} else {
252+
try {
253+
localStorage.setItem(STORAGE_PREFIX + 'narrator_voice_gender', narratorGender ? narratorGender.value : 'male');
254+
localStorage.setItem(STORAGE_PREFIX + 'narrator_voice_timbre', narratorTimbre ? narratorTimbre.value : 'rough');
255+
} catch (e) {}
256+
}
257+
}
258+
if (narratorGender) narratorGender.addEventListener('change', function() { updateNarratorVoice(); SoundManager.play('tap'); });
259+
if (narratorTimbre) narratorTimbre.addEventListener('change', function() { updateNarratorVoice(); SoundManager.play('tap'); });
260+
214261
// Narrator test
215262
var narratorTest = el.querySelector('#btn-test-narrator');
216263
if (narratorTest) narratorTest.addEventListener('click', function() {
217264
SoundManager.play('tap');
218265
if (typeof BattleNarrator !== 'undefined') {
266+
updateNarratorVoice();
219267
BattleNarrator.setEnabled(true);
220268
var toggle = el.querySelector('#narrator-toggle');
221269
if (toggle) {

app/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"short_name": "VizMagic",
44
"description": "Viz Magic — accessible browser RPG on the VIZ blockchain",
55
"id": "https://vizmagic.web3blind.xyz/",
6-
"start_url": "/?pwa=viz-magic-v49",
6+
"start_url": "/?pwa=viz-magic-v50",
77
"scope": "/",
88
"display": "standalone",
99
"display_override": ["standalone", "minimal-ui"],

app/sw.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Viz Magic — Service Worker
2-
var CACHE_NAME = 'viz-magic-v49';
2+
var CACHE_NAME = 'viz-magic-v50';
33
var ASSETS = [
44
'/',
55
'/index.html',

tests/player-bug-regressions.test.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ test('high-traffic UI narration, screen announcements, and inventory stat labels
345345

346346
test('service worker updates quickly and keeps navigations network-first', function () {
347347
const swJs = read('app/sw.js');
348-
assert.ok(/viz-magic-v49/.test(swJs), 'service worker cache version should be bumped');
348+
assert.ok(/viz-magic-v50/.test(swJs), 'service worker cache version should be bumped');
349349
assert.ok(/self\.skipWaiting\(\)/.test(swJs), 'service worker should activate new cache without waiting for all tabs to close');
350350
assert.ok(/self\.clients\.claim\(\)/.test(swJs), 'service worker should claim clients after activation');
351351
assert.ok(/event\.request\.mode === 'navigate'[\s\S]*fetch\(event\.request\)/.test(swJs), 'navigation requests should prefer network to avoid stale cached index');
@@ -394,7 +394,7 @@ test('mobile entry helpers cover keyboard paste, home-screen shortcut, nav parit
394394
assert.ok(/SoundManager\.setVolume\(sfxVolume \/ 100\)/.test(read('app/js/ui/screens/settings.js')), 'settings should apply stored SFX volume on render');
395395
assert.ok(/localStorage\.setItem\(STORAGE_PREFIX \+ 'sfx_volume'/.test(read('app/js/ui/sound.js')), 'sound manager should persist SFX volume');
396396
assert.ok(/var volume = _getStoredNumber\('sfx_volume', 0\.5\)/.test(read('app/js/ui/sound.js')), 'sound manager should restore persisted SFX volume');
397-
assert.ok(/viz-magic-v49/.test(read('app/sw.js')), 'service worker cache should be bumped for UI changes');
397+
assert.ok(/viz-magic-v50/.test(read('app/sw.js')), 'service worker cache should be bumped for UI changes');
398398
});
399399

400400

@@ -456,8 +456,8 @@ test('magical weather is labelled and affects hunts', function () {
456456
assert.ok(/forecast-card-effect/.test(homeJs + mainCss), 'forecast effect column needs its own thematic icon/card');
457457
assert.ok(/function getCurrentFestival/.test(worldEventsJs), 'magical holidays should sometimes appear in the forecast');
458458
assert.ok(/festival_today_prefix/.test(homeJs + ruJs + enJs), 'forecast holidays should have localized copy');
459-
assert.ok(/i18n\/ru.js\?v=20260713b/.test(indexHtml), 'Russian weather copy must be cache-busted');
460-
assert.ok(/i18n\/en.js\?v=20260713b/.test(indexHtml), 'English weather copy must be cache-busted');
459+
assert.ok(/i18n\/ru.js\?v=20260713c/.test(indexHtml), 'Russian weather copy must be cache-busted');
460+
assert.ok(/i18n\/en.js\?v=20260713c/.test(indexHtml), 'English weather copy must be cache-busted');
461461
assert.ok(/home.js\?v=20260713a/.test(indexHtml), 'home forecast layout must be cache-busted');
462462
assert.ok(/quests.js\?v=20260712a/.test(indexHtml), 'quest-limit UX must be cache-busted');
463463
assert.ok(/nav.js\?v=20260712b/.test(indexHtml), 'bottom tray nav must be cache-busted');
@@ -524,7 +524,7 @@ test('reported mobile UX issues have explicit fixes', function () {
524524

525525
test('PWA icon and HP heart use expressive color accents', function () {
526526
assert.ok(/viz-magic-192\.png\?v=20260713a/.test(indexHtml), 'PWA icon link should be cache-busted after plus placement/color update');
527-
assert.ok(/viz-magic-v49/.test(read('app/manifest.json')), 'manifest start URL should change so launchers can refresh icons');
527+
assert.ok(/viz-magic-v50/.test(read('app/manifest.json')), 'manifest start URL should change so launchers can refresh icons');
528528
assert.ok(/label:' HP'/.test(homeJs), 'HP label should use a red heart emoji variant');
529529
});
530530

@@ -551,6 +551,21 @@ test('hunt screen exposes explicit camp rest promised by Help', function () {
551551
assert.ok(/Отдых у костра/.test(ruJs) && /нажми «Отдых у костра»/.test(ruJs), 'Russian Help should name the visible rest button');
552552
});
553553

554+
555+
test('narrator voice preferences support gender and timbre', function () {
556+
const settingsJs = read('app/js/ui/screens/settings.js');
557+
const narratorJs = read('app/js/ui/components/battle-narrator.js');
558+
assert.ok(/battle-narrator.js\?v=20260713a/.test(indexHtml), 'battle narrator should be cache-busted');
559+
assert.ok(/settings.js\?v=20260713a/.test(indexHtml), 'settings should be cache-busted');
560+
assert.ok(/narrator-voice-gender/.test(settingsJs), 'settings should expose narrator gender select');
561+
assert.ok(/narrator-voice-timbre/.test(settingsJs), 'settings should expose narrator timbre select');
562+
assert.ok(/setVoiceOptions/.test(narratorJs), 'narrator should persist selectable voice options');
563+
assert.ok(/voiceGender = 'male'/.test(narratorJs) && /voiceTimbre = 'rough'/.test(narratorJs), 'default narrator voice should be low male/rough');
564+
assert.ok(/utterance\.pitch = \(voiceGender === 'male'\)/.test(narratorJs), 'narrator should adjust pitch by selected gender/timbre');
565+
assert.ok(/speechSynthesis\.getVoices/.test(narratorJs), 'narrator should try to select a matching system voice');
566+
assert.ok(/narrator_voice_hint/.test(settingsJs + ruJs + enJs), 'settings should explain browser voice limitations');
567+
});
568+
554569
if (process.exitCode) {
555570
process.exit(process.exitCode);
556571
}

0 commit comments

Comments
 (0)