PR2: naratiune vocala (SpeechSynthesis, D10) - opt-in din builder

Feature nou (vocea nu exista deloc). Opt-in via checkbox 'voice' in builder
(off implicit), buton toggle in bara chrome a campaniei (parintele detine).
Voicing orchestrator-only, uniform pe toate 5 motoarele (fara dublu-citit):
povestea la 'Incepe aventura', intrebarea camerei la roomReady, mesajul final.

Edge cases (toate tratate):
- getVoices() gol sincron -> re-citire la onvoiceschanged.
- fara voce ro-* -> vocea default a sistemului (doar u.lang='ro-RO').
- speechSynthesis.cancel() in hideAll() -> fara replici fantoma la schimbarea scenei.
- fara 'speechSynthesis' in window -> buton ascuns, totul no-op.
- window.voiceSay expus pe parinte pt. viitor (replici motoare cu guard typeof).

Bug prins de test: #btn-voice{display:inline-flex} batea UA [hidden] ->
adaugat #btn-voice[hidden]{display:none}.

Test nou smoke #9b (voce opt-in: buton, citeste poveste/intrebare, cancel,
toggle) + asertare buton-ascuns cand voice=false. Suita 25/25. Demo regenerat.
AGENTS.md/TODOS actualizate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-13 11:46:33 +00:00
parent 651025bd28
commit da93d8498c
6 changed files with 237 additions and 15 deletions

View File

@@ -49,6 +49,8 @@
}
legend { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; padding: 0 6px; }
label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); margin: 10px 0 3px; }
label.ck { display: flex; align-items: flex-start; gap: 7px; cursor: pointer; line-height: 1.4; }
label.ck input { width: auto; margin-top: 1px; flex-shrink: 0; }
label:first-of-type { margin-top: 0; }
input[type=text], textarea, select {
width: 100%; font: inherit; font-size: 14px; padding: 7px 9px;
@@ -132,6 +134,7 @@
<textarea id="gStory" data-g="story" rows="3"></textarea>
<label>Mesajul final (la castig)</label>
<textarea id="gFinal" data-g="finalMessage" rows="2"></textarea>
<label class="ck"><input type="checkbox" id="gVoice" data-gb="voice"> Naratiune vocala &mdash; citeste povestea si intrebarile cu vocea sistemului (doar in Campanie multi-stil)</label>
<label>Cuvantul final (din literele puzzle-urilor)</label>
<div class="word" id="finalWord">&nbsp;</div>
<div class="help">Se compune automat din campul "Litera" al fiecarui puzzle, in ordine.</div>
@@ -170,6 +173,7 @@ const defaultState = () => ({
color: '#6d28d9',
style: 'classic',
charName: 'Alex',
voice: false,
story: 'O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari. Rezolva fiecare puzzle ca sa aduni literele cuvantului magic.',
finalMessage: 'Felicitari! Ai gasit comoara!',
puzzles: [
@@ -214,6 +218,7 @@ const puzzleList = $('#puzzleList');
function renderGlobals() {
document.querySelectorAll('[data-g]').forEach(el => { el.value = state[el.dataset.g]; });
document.querySelectorAll('[data-gb]').forEach(el => { el.checked = !!state[el.dataset.gb]; });
renderWord();
}
@@ -310,6 +315,10 @@ document.querySelectorAll('[data-g]').forEach(el => {
});
});
document.querySelectorAll('[data-gb]').forEach(el => {
el.addEventListener('change', () => { state[el.dataset.gb] = el.checked; onChange(); });
});
puzzleList.addEventListener('input', e => {
const f = e.target.dataset.f;
if (!f) return;
@@ -1596,6 +1605,15 @@ body {
}
#chrome-title { font-size: 15px; font-weight: 700; }
#chrome .sp { flex: 1; }
#btn-voice {
width: 34px; height: 34px; min-width: 34px; padding: 0; border: 0; cursor: pointer;
border-radius: 8px; background: rgba(255,255,255,.12); color: #fff;
font-size: 17px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
}
#btn-voice[hidden] { display: none; } /* id batea specificitatea UA [hidden] */
#btn-voice:hover { background: rgba(255,255,255,.22); }
#btn-voice[aria-pressed="false"] { opacity: .5; }
#btn-voice:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; }
#dots { display: flex; gap: 8px; }
#dots span {
width: 10px; height: 10px; border-radius: 50%;
@@ -1758,6 +1776,7 @@ body {
<div id="chrome">
<span id="chrome-title">${esc(cfg.title)}</span>
<div class="sp"></div>
<button id="btn-voice" type="button" aria-label="Naratiune vocala" hidden>&#128266;</button>
<div id="dots"></div>
</div>
@@ -1913,6 +1932,40 @@ function _onFirstGesture(){
document.addEventListener('pointerdown',_onFirstGesture,true);
document.addEventListener('keydown',_onFirstGesture,true);
/* ----- Narațiune vocală (D10) — opt-in via MASTER.voice, buton în bara chrome.
Edge cases tratate: (1) getVoices() poate fi gol sincron → re-citim la voiceschanged;
(2) fără voce ro-* → vocea default a sistemului (nu setăm u.voice); (3) la fiecare
schimbare de scenă (hideAll) → speechSynthesis.cancel() (fără replici fantomă);
(4) fără API → butonul rămâne ascuns, totul devine no-op. */
var SPEECH = ('speechSynthesis' in window) && !!MASTER.voice;
var voiceOn = SPEECH; /* pornit implicit când feature-ul e activat din builder */
var _roVoice = null, _voicesReady = false;
function _pickVoice(){
try{
var vs = window.speechSynthesis.getVoices();
if(!vs || !vs.length) return; /* gol sincron — așteptăm voiceschanged */
_voicesReady = true;
_roVoice = vs.filter(function(v){ return /(^|[^a-z])ro([-_]|$)/i.test(v.lang||''); })[0] || null;
}catch(e){}
}
if(SPEECH){
_pickVoice();
try{ window.speechSynthesis.onvoiceschanged = _pickVoice; }catch(e){}
}
function voiceCancel(){ if(SPEECH){ try{ window.speechSynthesis.cancel(); }catch(e){} } }
function voiceSay(text){
if(!SPEECH || !voiceOn || !text) return;
try{
window.speechSynthesis.cancel();
if(!_voicesReady) _pickVoice();
var u = new SpeechSynthesisUtterance(String(text));
if(_roVoice){ u.voice = _roVoice; u.lang = _roVoice.lang; } else { u.lang = 'ro-RO'; }
u.rate = 1; u.pitch = 1;
window.speechSynthesis.speak(u);
}catch(e){}
}
window.voiceSay = voiceSay; /* expus camerelor: parent.voiceSay(replica) — guard în motoare */
/* ----- parent.* API ----- */
window.nextRoom = function(data){
@@ -1941,6 +1994,8 @@ window.roomReady = function(idx){
if(+idx !== activeIdx) return;
clearTimeout(readyTimer);
frameEl.setAttribute('data-room-ready','true'); /* hook pentru tests */
var q = MASTER.puzzles[idx] && MASTER.puzzles[idx].question;
if(q) voiceSay(q); /* citește întrebarea camerei (D10) */
};
window.roomError = function(idx, msg){
@@ -2051,6 +2106,7 @@ function showFinale(){
var pl = MASTER.player || '';
document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg;
beep(true); confetti();
voiceSay(document.getElementById('fin-msg').textContent); /* citește mesajul final (D10) */
}
/* ----- Confetti ----- */
@@ -2067,6 +2123,7 @@ function confetti(){
var overworldEl = document.getElementById('overworld');
function hideAll(){
voiceCancel(); /* fără replici fantomă la schimbarea scenei (D10) */
[introEl,overworldEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); });
}
@@ -2190,13 +2247,32 @@ owBuild();
/* ----- Intro ----- */
document.getElementById('intro-title').textContent = MASTER.title;
document.getElementById('intro-story').textContent = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story;
var _introStory = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story;
document.getElementById('intro-story').textContent = _introStory;
document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic';
document.getElementById('btn-start').onclick = function(){
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); showOverworld(0);
voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */
};
/* ----- Buton voce în bara chrome (D10) ----- */
var btnVoice = document.getElementById('btn-voice');
if(SPEECH && btnVoice){
btnVoice.hidden = false;
var _syncVoiceBtn = function(){
btnVoice.innerHTML = voiceOn ? '&#128266;' : '&#128263;'; /* 🔊 / 🔇 */
btnVoice.setAttribute('aria-pressed', voiceOn ? 'true' : 'false');
btnVoice.title = voiceOn ? 'Naratiune pornita — apasa ca sa opresti' : 'Naratiune oprita — apasa ca sa pornesti';
};
_syncVoiceBtn();
btnVoice.onclick = function(){
voiceOn = !voiceOn;
if(!voiceOn) voiceCancel();
_syncVoiceBtn();
};
}
buildDots();
/* ----- Resume la reload (D11) ----- */