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:
@@ -20,9 +20,9 @@ sursa de adevăr tehnică pentru agenți.
|
|||||||
python3 -m http.server 8000
|
python3 -m http.server 8000
|
||||||
|
|
||||||
# Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md):
|
# Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md):
|
||||||
npx playwright test tests/smoke.mjs # suita completă: 24/24
|
npx playwright test tests/smoke.mjs # suita completă: 25/25
|
||||||
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 14
|
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 14
|
||||||
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 10
|
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 11
|
||||||
```
|
```
|
||||||
|
|
||||||
## Durable Rules (repo-wide)
|
## Durable Rules (repo-wide)
|
||||||
|
|||||||
29
TODOS.md
29
TODOS.md
@@ -10,6 +10,15 @@ Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ▶ PR2 în curs (le iau pe rând, cerere user 2026-06-13)
|
||||||
|
- [x] **Audio camere** — fix REAL (vezi S1 mai jos, commit `651025b`): unlock pe primul gest global
|
||||||
|
(acoperă resume), nu doar btn-start; test rescris (headless crea ctx `running` trivial).
|
||||||
|
- [x] **Narațiune vocală (D10)** — LIVRAT (vezi §„Narațiune vocală" mai jos). Smoke 25/25.
|
||||||
|
- [ ] **Unificare `finale()` terminal pe `SNIP.finalJs`** (vezi §dedicată mai jos).
|
||||||
|
- [ ] **Audit a11y motoare** (vezi §dedicată mai jos).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ▶ BOARD ACTIV — Iterația 2 (Adventure Mode / restyle)
|
## ▶ BOARD ACTIV — Iterația 2 (Adventure Mode / restyle)
|
||||||
|
|
||||||
Direcția cerută de user (decizii confirmate, vezi `HANDOFF.md`). Model hibrid ca la PR1:
|
Direcția cerută de user (decizii confirmate, vezi `HANDOFF.md`). Model hibrid ca la PR1:
|
||||||
@@ -77,12 +86,20 @@ portează în `escape-builder.html` (un singur fișier, integrare secvențială)
|
|||||||
- Edge: muzica se oprește la `speechSynthesis.cancel()` dacă vocea e activă simultan.
|
- Edge: muzica se oprește la `speechSynthesis.cancel()` dacă vocea e activă simultan.
|
||||||
- Legat de: T10 (PR2), timer countdown în bara chrome (§Design pct. 10).
|
- Legat de: T10 (PR2), timer countdown în bara chrome (§Design pct. 10).
|
||||||
|
|
||||||
### Edge case-uri voce (SpeechSynthesis) — PR2
|
### [x] Narațiune vocală (SpeechSynthesis, D10) — LIVRAT (PR2)
|
||||||
- `speechSynthesis.getVoices()` poate fi gol sincron → ascultă `voiceschanged`.
|
Feature NOU (nu doar edge-cases — voce nu exista deloc). Opt-in din builder (checkbox
|
||||||
- Fără voce `ro-*` → fallback la vocea default (nu crash, nu tăcere).
|
`voice`, off implicit), buton 🔊/🔇 în bara chrome a campaniei (părinte deține). Orchestrator-only
|
||||||
- Voce activă mid-cameră → `speechSynthesis.cancel()` la demontare cameră (pater deține).
|
voicing (uniform pe toate 5 motoarele, fără dublu-citit): poveste la „Începe aventura", întrebarea
|
||||||
- `parent.voiceSay(text)` = no-op în jocurile simple (funcția nu există) → guard `typeof parent.voiceSay === 'function'`.
|
camerei la `roomReady`, mesajul final la `showFinale`. Toate edge-case-urile tratate:
|
||||||
- Referință: D10 din plan; E2 Etapa 2 pct. 3.
|
- `getVoices()` gol sincron → re-citire la `onvoiceschanged` (`_pickVoice`).
|
||||||
|
- Fără voce `ro-*` → vocea default (nu setăm `u.voice`, doar `u.lang='ro-RO'`).
|
||||||
|
- `speechSynthesis.cancel()` în `hideAll()` → fără replici fantomă la schimbarea scenei.
|
||||||
|
- Fără `speechSynthesis` în window → buton ascuns, tot devine no-op.
|
||||||
|
- `window.voiceSay` expus pe părinte (pt. viitor: replici din motoare cu guard `typeof`).
|
||||||
|
Bug prins de test: `#btn-voice{display:inline-flex}` bătea UA `[hidden]` → adăugat `[hidden]{display:none}`.
|
||||||
|
Verificat: smoke 25/25 (test nou „voce — naratiune opt-in") + live MCP (buton, toggle, checkbox builder).
|
||||||
|
NOTĂ scope: motoarele NU cheamă încă `parent.voiceSay` (am evitat dublu-citit cu roomReady); dacă
|
||||||
|
pe viitor vrei replici chat citite individual, adaugă în `charMsg` cu guard `typeof parent.voiceSay`.
|
||||||
|
|
||||||
### Unificarea `finale()` din terminal pe `SNIP.finalJs` (PR2 primul pas)
|
### Unificarea `finale()` din terminal pe `SNIP.finalJs` (PR2 primul pas)
|
||||||
- Astăzi terminalul are propria funcție `finale()` (escape-builder.html:863) care NU folosește `SNIP.finalJs`.
|
- Astăzi terminalul are propria funcție `finale()` (escape-builder.html:863) care NU folosește `SNIP.finalJs`.
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
}
|
}
|
||||||
legend { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; padding: 0 6px; }
|
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 { 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; }
|
label:first-of-type { margin-top: 0; }
|
||||||
input[type=text], textarea, select {
|
input[type=text], textarea, select {
|
||||||
width: 100%; font: inherit; font-size: 14px; padding: 7px 9px;
|
width: 100%; font: inherit; font-size: 14px; padding: 7px 9px;
|
||||||
@@ -132,6 +134,7 @@
|
|||||||
<textarea id="gStory" data-g="story" rows="3"></textarea>
|
<textarea id="gStory" data-g="story" rows="3"></textarea>
|
||||||
<label>Mesajul final (la castig)</label>
|
<label>Mesajul final (la castig)</label>
|
||||||
<textarea id="gFinal" data-g="finalMessage" rows="2"></textarea>
|
<textarea id="gFinal" data-g="finalMessage" rows="2"></textarea>
|
||||||
|
<label class="ck"><input type="checkbox" id="gVoice" data-gb="voice"> Naratiune vocala — citeste povestea si intrebarile cu vocea sistemului (doar in Campanie multi-stil)</label>
|
||||||
<label>Cuvantul final (din literele puzzle-urilor)</label>
|
<label>Cuvantul final (din literele puzzle-urilor)</label>
|
||||||
<div class="word" id="finalWord"> </div>
|
<div class="word" id="finalWord"> </div>
|
||||||
<div class="help">Se compune automat din campul "Litera" al fiecarui puzzle, in ordine.</div>
|
<div class="help">Se compune automat din campul "Litera" al fiecarui puzzle, in ordine.</div>
|
||||||
@@ -170,6 +173,7 @@ const defaultState = () => ({
|
|||||||
color: '#6d28d9',
|
color: '#6d28d9',
|
||||||
style: 'classic',
|
style: 'classic',
|
||||||
charName: 'Alex',
|
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.',
|
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!',
|
finalMessage: 'Felicitari! Ai gasit comoara!',
|
||||||
puzzles: [
|
puzzles: [
|
||||||
@@ -214,6 +218,7 @@ const puzzleList = $('#puzzleList');
|
|||||||
|
|
||||||
function renderGlobals() {
|
function renderGlobals() {
|
||||||
document.querySelectorAll('[data-g]').forEach(el => { el.value = state[el.dataset.g]; });
|
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();
|
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 => {
|
puzzleList.addEventListener('input', e => {
|
||||||
const f = e.target.dataset.f;
|
const f = e.target.dataset.f;
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
@@ -1596,6 +1605,15 @@ body {
|
|||||||
}
|
}
|
||||||
#chrome-title { font-size: 15px; font-weight: 700; }
|
#chrome-title { font-size: 15px; font-weight: 700; }
|
||||||
#chrome .sp { flex: 1; }
|
#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 { display: flex; gap: 8px; }
|
||||||
#dots span {
|
#dots span {
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
@@ -1758,6 +1776,7 @@ body {
|
|||||||
<div id="chrome">
|
<div id="chrome">
|
||||||
<span id="chrome-title">${esc(cfg.title)}</span>
|
<span id="chrome-title">${esc(cfg.title)}</span>
|
||||||
<div class="sp"></div>
|
<div class="sp"></div>
|
||||||
|
<button id="btn-voice" type="button" aria-label="Naratiune vocala" hidden>🔊</button>
|
||||||
<div id="dots"></div>
|
<div id="dots"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1913,6 +1932,40 @@ function _onFirstGesture(){
|
|||||||
document.addEventListener('pointerdown',_onFirstGesture,true);
|
document.addEventListener('pointerdown',_onFirstGesture,true);
|
||||||
document.addEventListener('keydown',_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 ----- */
|
/* ----- parent.* API ----- */
|
||||||
|
|
||||||
window.nextRoom = function(data){
|
window.nextRoom = function(data){
|
||||||
@@ -1941,6 +1994,8 @@ window.roomReady = function(idx){
|
|||||||
if(+idx !== activeIdx) return;
|
if(+idx !== activeIdx) return;
|
||||||
clearTimeout(readyTimer);
|
clearTimeout(readyTimer);
|
||||||
frameEl.setAttribute('data-room-ready','true'); /* hook pentru tests */
|
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){
|
window.roomError = function(idx, msg){
|
||||||
@@ -2051,6 +2106,7 @@ function showFinale(){
|
|||||||
var pl = MASTER.player || '';
|
var pl = MASTER.player || '';
|
||||||
document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg;
|
document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg;
|
||||||
beep(true); confetti();
|
beep(true); confetti();
|
||||||
|
voiceSay(document.getElementById('fin-msg').textContent); /* citește mesajul final (D10) */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----- Confetti ----- */
|
/* ----- Confetti ----- */
|
||||||
@@ -2067,6 +2123,7 @@ function confetti(){
|
|||||||
|
|
||||||
var overworldEl = document.getElementById('overworld');
|
var overworldEl = document.getElementById('overworld');
|
||||||
function hideAll(){
|
function hideAll(){
|
||||||
|
voiceCancel(); /* fără replici fantomă la schimbarea scenei (D10) */
|
||||||
[introEl,overworldEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); });
|
[introEl,overworldEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2190,13 +2247,32 @@ owBuild();
|
|||||||
|
|
||||||
/* ----- Intro ----- */
|
/* ----- Intro ----- */
|
||||||
document.getElementById('intro-title').textContent = MASTER.title;
|
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('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic';
|
||||||
document.getElementById('btn-start').onclick = function(){
|
document.getElementById('btn-start').onclick = function(){
|
||||||
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
|
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
|
||||||
clearProgress(); owResetPlayer(); showOverworld(0);
|
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 ? '🔊' : '🔇'; /* 🔊 / 🔇 */
|
||||||
|
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();
|
buildDots();
|
||||||
|
|
||||||
/* ----- Resume la reload (D11) ----- */
|
/* ----- Resume la reload (D11) ----- */
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ body {
|
|||||||
}
|
}
|
||||||
#chrome-title { font-size: 15px; font-weight: 700; }
|
#chrome-title { font-size: 15px; font-weight: 700; }
|
||||||
#chrome .sp { flex: 1; }
|
#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 { display: flex; gap: 8px; }
|
||||||
#dots span {
|
#dots span {
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
@@ -192,6 +201,7 @@ body {
|
|||||||
<div id="chrome">
|
<div id="chrome">
|
||||||
<span id="chrome-title">Comoara ascunsa</span>
|
<span id="chrome-title">Comoara ascunsa</span>
|
||||||
<div class="sp"></div>
|
<div class="sp"></div>
|
||||||
|
<button id="btn-voice" type="button" aria-label="Naratiune vocala" hidden>🔊</button>
|
||||||
<div id="dots"></div>
|
<div id="dots"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -347,6 +357,40 @@ function _onFirstGesture(){
|
|||||||
document.addEventListener('pointerdown',_onFirstGesture,true);
|
document.addEventListener('pointerdown',_onFirstGesture,true);
|
||||||
document.addEventListener('keydown',_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 ----- */
|
/* ----- parent.* API ----- */
|
||||||
|
|
||||||
window.nextRoom = function(data){
|
window.nextRoom = function(data){
|
||||||
@@ -375,6 +419,8 @@ window.roomReady = function(idx){
|
|||||||
if(+idx !== activeIdx) return;
|
if(+idx !== activeIdx) return;
|
||||||
clearTimeout(readyTimer);
|
clearTimeout(readyTimer);
|
||||||
frameEl.setAttribute('data-room-ready','true'); /* hook pentru tests */
|
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){
|
window.roomError = function(idx, msg){
|
||||||
@@ -485,6 +531,7 @@ function showFinale(){
|
|||||||
var pl = MASTER.player || '';
|
var pl = MASTER.player || '';
|
||||||
document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg;
|
document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg;
|
||||||
beep(true); confetti();
|
beep(true); confetti();
|
||||||
|
voiceSay(document.getElementById('fin-msg').textContent); /* citește mesajul final (D10) */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----- Confetti ----- */
|
/* ----- Confetti ----- */
|
||||||
@@ -501,6 +548,7 @@ function confetti(){
|
|||||||
|
|
||||||
var overworldEl = document.getElementById('overworld');
|
var overworldEl = document.getElementById('overworld');
|
||||||
function hideAll(){
|
function hideAll(){
|
||||||
|
voiceCancel(); /* fără replici fantomă la schimbarea scenei (D10) */
|
||||||
[introEl,overworldEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); });
|
[introEl,overworldEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,13 +672,32 @@ owBuild();
|
|||||||
|
|
||||||
/* ----- Intro ----- */
|
/* ----- Intro ----- */
|
||||||
document.getElementById('intro-title').textContent = MASTER.title;
|
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 3 stiluri \u00b7 1 cuvânt magic';
|
document.getElementById('intro-promise').textContent = N+' camere \u00b7 3 stiluri \u00b7 1 cuvânt magic';
|
||||||
document.getElementById('btn-start').onclick = function(){
|
document.getElementById('btn-start').onclick = function(){
|
||||||
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
|
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
|
||||||
clearProgress(); owResetPlayer(); showOverworld(0);
|
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 ? '🔊' : '🔇'; /* 🔊 / 🔇 */
|
||||||
|
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();
|
buildDots();
|
||||||
|
|
||||||
/* ----- Resume la reload (D11) ----- */
|
/* ----- Resume la reload (D11) ----- */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Smoke + regresie + campanie E2E pentru jocurile generate. Verifică faptic: fiec
|
|||||||
până la ecranul final, fără erori de consolă.
|
până la ecranul final, fără erori de consolă.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
- `tests/smoke.mjs` — unicul fișier de teste (~24 teste).
|
- `tests/smoke.mjs` — unicul fișier de teste (~25 teste).
|
||||||
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
|
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
|
||||||
|
|
||||||
## Local Contracts
|
## Local Contracts
|
||||||
@@ -17,9 +17,9 @@ până la ecranul final, fără erori de consolă.
|
|||||||
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
|
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
|
||||||
fiecare test asertează `errors.length === 0` la final.
|
fiecare test asertează `errors.length === 0` la final.
|
||||||
- **Tag-uri:** `@regresie` (14 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML +
|
- **Tag-uri:** `@regresie` (14 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML +
|
||||||
bomberman gameplay) și `@campanie` (10 — intro→hartă→camere→final, resume, cameră moartă,
|
bomberman gameplay) și `@campanie` (11 — intro→hartă→camere→final, resume, cameră moartă,
|
||||||
idempotență ușă, `$`/`$&`, beep, mobil, audio S1, navigare overworld).
|
idempotență ușă, `$`/`$&`, beep, mobil, audio S1, voce/narațiune D10, navigare overworld).
|
||||||
- **Status țintă: 24/24 PASS.**
|
- **Status țintă: 25/25 PASS.**
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
- După modificări la motoare (`escape-builder.html`): rulează suita completă; extinde `@regresie` dacă
|
- După modificări la motoare (`escape-builder.html`): rulează suita completă; extinde `@regresie` dacă
|
||||||
@@ -28,7 +28,7 @@ până la ecranul final, fără erori de consolă.
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
```bash
|
```bash
|
||||||
npx playwright test tests/smoke.mjs # 24/24
|
npx playwright test tests/smoke.mjs # 25/25
|
||||||
npx playwright test tests/smoke.mjs --grep @regresie
|
npx playwright test tests/smoke.mjs --grep @regresie
|
||||||
npx playwright test tests/smoke.mjs --grep @campanie
|
npx playwright test tests/smoke.mjs --grep @campanie
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -958,6 +958,8 @@ test.describe('Campanie E2E @campanie', () => {
|
|||||||
const gp = await page.context().newPage();
|
const gp = await page.context().newPage();
|
||||||
try {
|
try {
|
||||||
await gp.goto('file://' + tmpPath);
|
await gp.goto('file://' + tmpPath);
|
||||||
|
// Fara voice in cfg → butonul de naratiune ramane ascuns (opt-in, D10)
|
||||||
|
await expect(gp.locator('#btn-voice')).toBeHidden();
|
||||||
// Inainte de orice gest: ctx inexistent (creat lazy)
|
// Inainte de orice gest: ctx inexistent (creat lazy)
|
||||||
const before = await gp.evaluate(
|
const before = await gp.evaluate(
|
||||||
() => (window.beep && window.beep._ctx) ? window.beep._ctx.state : 'NO_CTX'
|
() => (window.beep && window.beep._ctx) ? window.beep._ctx.state : 'NO_CTX'
|
||||||
@@ -993,6 +995,66 @@ test.describe('Campanie E2E @campanie', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 9b (PR2): Voce — naratiune opt-in (D10). Headless nu reda audio, dar
|
||||||
|
// spionam speechSynthesis.speak/cancel: butonul apare doar la voice=true,
|
||||||
|
// povestea+intrebarea sunt citite, schimbarea scenei cheama cancel, iar
|
||||||
|
// toggle-off face voiceSay no-op.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test('voce — naratiune opt-in: buton, citeste poveste/intrebare, cancel + toggle @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
const cfg = campaignCfg(3, 'classic');
|
||||||
|
cfg.voice = true;
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'voice');
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
// Spy: inlocuim speak (sa nu redea real) si numaram cancel
|
||||||
|
await gp.evaluate(() => {
|
||||||
|
window.__spoken = []; window.__cancels = 0;
|
||||||
|
const ss = window.speechSynthesis;
|
||||||
|
ss.speak = (u) => { window.__spoken.push(String((u && u.text) || '')); };
|
||||||
|
const oc = ss.cancel.bind(ss);
|
||||||
|
ss.cancel = () => { window.__cancels++; try { oc(); } catch (e) {} };
|
||||||
|
});
|
||||||
|
// Butonul apare cand voice=true, pornit implicit
|
||||||
|
await expect(gp.locator('#btn-voice')).toBeVisible();
|
||||||
|
expect(await gp.locator('#btn-voice').getAttribute('aria-pressed')).toBe('true');
|
||||||
|
|
||||||
|
// Start → citeste povestea
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
await waitOverworld(gp);
|
||||||
|
await gp.waitForTimeout(120);
|
||||||
|
const afterStart = await gp.evaluate(() => window.__spoken.slice());
|
||||||
|
expect(afterStart.some(t => t.includes('O campanie de test')),
|
||||||
|
'povestea trebuie citita la start').toBeTruthy();
|
||||||
|
|
||||||
|
// Intra in camera → roomReady citeste intrebarea; tranzitia cheama cancel
|
||||||
|
await enterRoom(gp, 0);
|
||||||
|
await gp.waitForFunction(
|
||||||
|
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
|
||||||
|
null, { timeout: 8000 }
|
||||||
|
);
|
||||||
|
await gp.waitForTimeout(120);
|
||||||
|
const afterRoom = await gp.evaluate(() => window.__spoken.slice());
|
||||||
|
expect(afterRoom.some(t => t.includes('Raspunde 1')),
|
||||||
|
'intrebarea camerei trebuie citita la roomReady').toBeTruthy();
|
||||||
|
expect(await gp.evaluate(() => window.__cancels),
|
||||||
|
'schimbarea scenei trebuie sa cheme speechSynthesis.cancel').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Toggle off → aria-pressed false + voiceSay devine no-op
|
||||||
|
await gp.locator('#btn-voice').click();
|
||||||
|
expect(await gp.locator('#btn-voice').getAttribute('aria-pressed')).toBe('false');
|
||||||
|
await gp.evaluate(() => window.voiceSay('NU_TREBUIE_CITIT'));
|
||||||
|
const spokenAfter = await gp.evaluate(() => window.__spoken.slice());
|
||||||
|
expect(spokenAfter.includes('NU_TREBUIE_CITIT'),
|
||||||
|
'voiceSay trebuie no-op cand naratiunea e oprita').toBeFalsy();
|
||||||
|
} finally {
|
||||||
|
await gp.close();
|
||||||
|
try { unlinkSync(tmpPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Test 10 (S4): Overworld — mers cu tastatura + iesire blocata pana la final
|
// Test 10 (S4): Overworld — mers cu tastatura + iesire blocata pana la final
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user