diff --git a/TODOS.md b/TODOS.md index e2408f1..e30a03f 100644 --- a/TODOS.md +++ b/TODOS.md @@ -16,13 +16,18 @@ Direcția cerută de user (decizii confirmate, vezi `HANDOFF.md`). Model hibrid părțile grele se prototipează în PARALEL în `scratch/`, verificate jucabile, apoi integrator le portează în `escape-builder.html` (un singur fișier, integrare secvențială). -- [x] **S1 — fix sunet campanie** *(GATA, verificat în browser)* - Cauză reală: orchestratorul crea `beep._ctx` lazy la primul `parent.beep()` din iframe; - gestul din iframe NU deblochează AudioContext-ul părintelui → ctx `suspended` → tăcere. - (Ipoteza HANDOFF „beep nedefinit" era greșită; `beep` e la `escape-builder.html:1725`.) - Fix: deblocare ctx în handler-ul `btn-start` (gest direct pe părinte), `escape-builder.html:1928`. - Verificat: `scratch/verify-audio-s1.mjs` → ctx `running` după start (era `NO_CTX`). Smoke 21/21. - TODO la S4: portează asertarea `beep._ctx.state==='running'` în `tests/smoke.mjs`. +- [x] **S1 — fix sunet campanie** *(GATA — REVENIT: fix-ul inițial era incomplet, user raporta tăcere)* + Cauză reală: gestul din iframe NU deblochează AudioContext-ul părintelui → ctx `suspended` → tăcere. + Fix v1 (incomplet): deblocare DOAR în handler-ul `btn-start`. Lacună: calea de **resume** + (reload mid-campanie, `escape-builder.html:2199`) intră direct pe hartă FĂRĂ btn-start → ctx + nedeblocat → camere mute. Plus `resume()` singur nu ajunge pe iOS Safari. + Fix v2 (real): `unlockAudio()` + listener GLOBAL one-time pe primul gest (`pointerdown`+`keydown`, + capture) — acoperă fresh ȘI resume (mers pe hartă = keydown pe părinte); buffer silențios + iOS-safe; `beep()` se auto-vindecă dacă ctx redevine `suspended`. `escape-builder.html:1893`. + **Lecție testare:** headless Chromium creează ctx direct `running` (ignoră autoplay policy) → + vechiul test „ctx running" trecea trivial, NU putea prinde tăcerea. Test nou (smoke #9): + gest tastatură FĂRĂ btn-start → running (cale resume) + beep self-heal din ctx suspendat. + Verificat: smoke 24/24 + live MCP (ArrowDown singur deblochează). Demo-uri regenerate. - [x] **S2a — prototip Bomberman complet** → `scratch/bomberman-proto.html` (GATA, 8/8 verificat de mine) Grid 15×13, bombe timer 2.4s + explozii lanț, cutii distructibile, AI dușmani BFS urmărire, 3 vieți + respawn cu progres puzzle PĂSTRAT (stare separată), PRNG seedat (`window.__seed`), diff --git a/escape-builder.html b/escape-builder.html index 2fe4473..5da8400 100644 --- a/escape-builder.html +++ b/escape-builder.html @@ -1878,6 +1878,7 @@ function doorHtml(style, isLast, isStuck){ function beep(ok){ try{ var ctx=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); + if(ctx.state==='suspended') ctx.resume(); /* safety: ctx poate fi suspendat din nou */ var t=ctx.currentTime; var fs=ok?[523,784]:[196]; fs.forEach(function(f,k){ var o=ctx.createOscillator(),g=ctx.createGain(); @@ -1890,6 +1891,28 @@ function beep(ok){ }catch(e){} } +/* ----- Deblocare audio (D2) — primul gest pe părinte creează+deblochează ctx-ul. + Necesar pe TOATE căile, nu doar btn-start: la resume (reload mid-campanie) se intră + direct pe hartă fără btn-start, iar camerele cheamă parent.beep() din iframe (gestul + din iframe NU deblochează ctx-ul părintelui). Pe iOS Safari resume() singur nu ajunge + → redăm și un buffer silențios în gest. Listener one-time, se auto-elimină. */ +function unlockAudio(){ + try{ + var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); + if(c.state==='suspended') c.resume(); + var b=c.createBuffer(1,1,22050),s=c.createBufferSource(); + s.buffer=b; s.connect(c.destination); s.start(0); + }catch(e){} +} +var _audioUnlocked=false; +function _onFirstGesture(){ + if(_audioUnlocked) return; _audioUnlocked=true; unlockAudio(); + document.removeEventListener('pointerdown',_onFirstGesture,true); + document.removeEventListener('keydown',_onFirstGesture,true); +} +document.addEventListener('pointerdown',_onFirstGesture,true); +document.addEventListener('keydown',_onFirstGesture,true); + /* ----- parent.* API ----- */ window.nextRoom = function(data){ @@ -2170,9 +2193,7 @@ document.getElementById('intro-title').textContent = MASTER.title; document.getElementById('intro-story').textContent = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story; document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic'; document.getElementById('btn-start').onclick = function(){ - /* Deblochează AudioContext-ul AICI (gest direct pe părinte) — camerele cheamă - parent.beep() din iframe, iar gestul din iframe NU deblochează ctx-ul părintelui. */ - try{ var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); if(c.state==='suspended') c.resume(); }catch(e){} + unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */ clearProgress(); owResetPlayer(); showOverworld(0); }; diff --git a/exemplu-campanie.html b/exemplu-campanie.html index f540652..d070131 100644 --- a/exemplu-campanie.html +++ b/exemplu-campanie.html @@ -312,6 +312,7 @@ function doorHtml(style, isLast, isStuck){ function beep(ok){ try{ var ctx=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); + if(ctx.state==='suspended') ctx.resume(); /* safety: ctx poate fi suspendat din nou */ var t=ctx.currentTime; var fs=ok?[523,784]:[196]; fs.forEach(function(f,k){ var o=ctx.createOscillator(),g=ctx.createGain(); @@ -324,6 +325,28 @@ function beep(ok){ }catch(e){} } +/* ----- Deblocare audio (D2) — primul gest pe părinte creează+deblochează ctx-ul. + Necesar pe TOATE căile, nu doar btn-start: la resume (reload mid-campanie) se intră + direct pe hartă fără btn-start, iar camerele cheamă parent.beep() din iframe (gestul + din iframe NU deblochează ctx-ul părintelui). Pe iOS Safari resume() singur nu ajunge + → redăm și un buffer silențios în gest. Listener one-time, se auto-elimină. */ +function unlockAudio(){ + try{ + var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); + if(c.state==='suspended') c.resume(); + var b=c.createBuffer(1,1,22050),s=c.createBufferSource(); + s.buffer=b; s.connect(c.destination); s.start(0); + }catch(e){} +} +var _audioUnlocked=false; +function _onFirstGesture(){ + if(_audioUnlocked) return; _audioUnlocked=true; unlockAudio(); + document.removeEventListener('pointerdown',_onFirstGesture,true); + document.removeEventListener('keydown',_onFirstGesture,true); +} +document.addEventListener('pointerdown',_onFirstGesture,true); +document.addEventListener('keydown',_onFirstGesture,true); + /* ----- parent.* API ----- */ window.nextRoom = function(data){ @@ -604,9 +627,7 @@ document.getElementById('intro-title').textContent = MASTER.title; document.getElementById('intro-story').textContent = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story; document.getElementById('intro-promise').textContent = N+' camere \u00b7 3 stiluri \u00b7 1 cuvânt magic'; document.getElementById('btn-start').onclick = function(){ - /* Deblochează AudioContext-ul AICI (gest direct pe părinte) — camerele cheamă - parent.beep() din iframe, iar gestul din iframe NU deblochează ctx-ul părintelui. */ - try{ var c=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)()); if(c.state==='suspended') c.resume(); }catch(e){} + unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */ clearProgress(); owResetPlayer(); showOverworld(0); }; diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 48bbcc6..505c8c4 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -945,27 +945,48 @@ test.describe('Campanie E2E @campanie', () => { }); // ───────────────────────────────────────────────────────────────────── - // Test 9 (S4): Audio — AudioContext deblocat la "Incepe aventura" (S1) + // Test 9 (S4+): Audio — deblocare ctx pe PRIMUL gest (orice), nu doar btn-start. + // NB: headless Chromium creeaza AudioContext direct 'running' (ignora autoplay + // policy), deci "ctx==running" e trivial. Testam WIRING-ul real al deblocarii: + // (A) primul gest oarecare (tastatura, ca la resume) deblocheaza fara btn-start; + // (B) beep() se auto-vindeca dintr-un ctx readus in 'suspended'. // ───────────────────────────────────────────────────────────────────── - test('audio — AudioContext deblocat la Incepe aventura (S1) @campanie', + test('audio — deblocare pe primul gest + beep self-heal (S1) @campanie', async ({ page }) => { const cfg = campaignCfg(3, 'classic'); const tmpPath = await writeCampaignHtml(page, cfg, 'audio'); const gp = await page.context().newPage(); try { await gp.goto('file://' + tmpPath); - // Inainte de gest: ctx inexistent (creat lazy) + // Inainte de orice gest: ctx inexistent (creat lazy) const before = await gp.evaluate( () => (window.beep && window.beep._ctx) ? window.beep._ctx.state : 'NO_CTX' ); expect(before, 'ctx nu trebuie sa existe inainte de gest').toBe('NO_CTX'); - // Gestul pe parinte deblocheaza ctx-ul - await gp.locator('#btn-start').click(); - await gp.waitForTimeout(200); - const after = await gp.evaluate( + + // (A) Cale RESUME: un gest de tastatura (mers pe harta), FARA btn-start, + // trebuie sa creeze+deblocheze ctx-ul prin listenerul global one-time. + await gp.keyboard.press('ArrowDown'); + await gp.waitForTimeout(100); + const afterKey = await gp.evaluate( () => (window.beep && window.beep._ctx) ? window.beep._ctx.state : 'NO_CTX' ); - expect(after, 'ctx trebuie running dupa Incepe aventura').toBe('running'); + expect(afterKey, 'gestul de tastatura trebuie sa deblocheze ctx (cale resume)').toBe('running'); + + // (B) beep() trebuie sa produca oscilatoare si sa reia un ctx suspendat. + const heal = await gp.evaluate(async () => { + await window.beep._ctx.suspend(); + const mid = window.beep._ctx.state; // 'suspended' + let osc = 0; + const orig = window.beep._ctx.createOscillator.bind(window.beep._ctx); + window.beep._ctx.createOscillator = function () { osc++; return orig(); }; + window.beep(true); + await new Promise(r => setTimeout(r, 50)); + return { mid, osc, end: window.beep._ctx.state }; + }); + expect(heal.mid, 'ctx trebuie suspendat inainte de self-heal').toBe('suspended'); + expect(heal.osc, 'beep trebuie sa creeze oscilatoare').toBeGreaterThan(0); + expect(heal.end, 'beep trebuie sa reia ctx-ul suspendat').toBe('running'); } finally { await gp.close(); try { unlinkSync(tmpPath); } catch (_) {}