Timer Calm (T10 / §Design pct.10): ceas campanie opt-in

Ceas M:SS in bara chrome a campaniei. Opt-in din builder (camp "Timp limita
(minute)", default 0 = fara; cleanState coerce 0..120).

- porneste exact la "Incepe aventura" (intro necronometrat)
- deadline ABSOLUT in sessionStorage -> resume nu reseteaza ceasul
- sub 1 min -> auriu (.low); expirare -> ingheata 0:00 + marcaj discret
  (.expired), jocul curge nestingherit (zero penalizare, stelele raman)
- fara rosu pulsant (public copii) -> reduced-motion safe by default
- exemplu-campanie.html regenerat (ramane fara timer - opt-in, ca vocea)

Fundatie pentru muzica T10 (accelerare sub 1 min) + footer diploma.
Test nou (smoke 29/29): format M:SS, prag auriu, freeze la expirare,
jocul continua dupa expirare, resume pastreaza ceasul.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-13 20:06:36 +00:00
parent 16cd521430
commit b359bbe50a
7 changed files with 189 additions and 15 deletions

View File

@@ -135,6 +135,9 @@
<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>Timp limita (minute, 0 = fara) &mdash; ceas calm in bara, doar in Campanie</label>
<input type="number" id="gTimer" data-g="timerMin" min="0" max="120" step="1" placeholder="0">
<div class="help">Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 &mdash; jocul continua, fara penalizare.</div>
<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>
@@ -176,6 +179,7 @@ const defaultState = () => ({
style: 'classic',
charName: 'Alex',
voice: false,
timerMin: 0,
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: [
@@ -407,6 +411,7 @@ $('#btnReload').addEventListener('click', refreshPreview);
function cleanState() {
const s = JSON.parse(JSON.stringify(state));
s.timerMin = Math.max(0, Math.min(120, parseInt(s.timerMin, 10) || 0)); /* T10: minute întregi 0..120 */
s.puzzles.forEach(p => {
delete p._closed;
/* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */
@@ -1621,6 +1626,14 @@ body {
}
#chrome-title { font-size: 15px; font-weight: 700; }
#chrome .sp { flex: 1; }
/* Timer Calm (§Design pct.10) — neutru; auriu sub 1 min; înghețat la expirare (fără roșu pulsant) */
#chrome-timer {
font-variant-numeric: tabular-nums; font-weight: 700; font-size: 15px;
color: var(--c-ink); letter-spacing: .02em; min-width: 3.1em; text-align: right;
}
#chrome-timer[hidden] { display: none; }
#chrome-timer.low { color: var(--c-gold); }
#chrome-timer.expired { color: var(--c-gold); opacity: .55; }
#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;
@@ -1788,6 +1801,7 @@ body {
@media (max-width: 599px) {
#chrome { height: 40px; min-height: 40px; }
#chrome-title { font-size: 13px; }
#chrome-timer { font-size: 13px; }
#dots span { width: 8px; height: 8px; }
}
</style>
@@ -1797,6 +1811,7 @@ body {
<div id="chrome">
<span id="chrome-title">${esc(cfg.title)}</span>
<div class="sp"></div>
<span id="chrome-timer" role="timer" aria-label="Timp ramas" hidden>0:00</span>
<button id="btn-voice" type="button" aria-label="Naratiune vocala" hidden>&#128266;</button>
<div id="dots" role="group" aria-label="Progres camere"></div>
</div>
@@ -1883,7 +1898,38 @@ function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v))
function saveProgress(){
safeSet({ idx: activeIdx, totalStars: totalStars, collected: collected.slice(), skipped: skipped });
}
function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); }catch(e){} }
function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); sessionStorage.removeItem(_DEADLINE_KEY); }catch(e){} }
/* ----- Timer Calm (§Design pct.10) — ceas M:SS în chrome.
Pornește la „Începe aventura"; deadline ABSOLUT în sessionStorage → resume-ul
(reload mid-campanie) NU resetează ceasul. Sub 1 min → auriu. La expirare îngheață
pe 0:00 + marcaj discret, jocul curge nestingherit (zero penalizare). ----- */
var TIMER_SEC = (+MASTER.timerMin || 0) * 60;
var _DEADLINE_KEY = _RESUME_KEY + '-dl';
var timerEl = document.getElementById('chrome-timer');
var _deadline = 0, _timerInt = null, _timerExpired = false;
function _fmt(s){ var m = Math.floor(s/60), ss = s % 60; return m + ':' + (ss < 10 ? '0' : '') + ss; }
function tickTimer(){
if(!_deadline){ return; }
var rem = Math.round((_deadline - Date.now()) / 1000);
if(rem <= 0){
rem = 0;
if(!_timerExpired){ _timerExpired = true; timerEl.classList.add('expired'); timerEl.title = 'Timpul a expirat — jocul continua'; }
if(_timerInt){ clearInterval(_timerInt); _timerInt = null; }
}
timerEl.textContent = _fmt(rem);
if(rem <= 60) timerEl.classList.add('low');
}
function stopTimer(){ if(_timerInt){ clearInterval(_timerInt); _timerInt = null; } }
function startTimer(){
if(TIMER_SEC <= 0) return;
timerEl.hidden = false;
var existing = 0; try{ existing = +sessionStorage.getItem(_DEADLINE_KEY) || 0; }catch(e){}
if(existing > 0){ _deadline = existing; } /* resume → păstrează ceasul */
else { _deadline = Date.now() + TIMER_SEC * 1000; try{ sessionStorage.setItem(_DEADLINE_KEY, String(_deadline)); }catch(e){} }
tickTimer();
if(!_timerInt && !_timerExpired) _timerInt = setInterval(tickTimer, 1000);
}
var frameEl = document.getElementById('room-frame');
var introEl = document.getElementById('intro');
@@ -2112,6 +2158,7 @@ function showSkipBanner(idx, code, reason){
/* ----- Final ----- */
function showFinale(){
stopTimer(); /* jocul s-a încheiat — oprește ceasul */
hideAll(); finaleEl.classList.add('show');
var wEl = document.getElementById('fin-word'); wEl.innerHTML = '';
collected.forEach(function(l,j){
@@ -2280,6 +2327,7 @@ document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nSty
document.getElementById('btn-start').onclick = function(){
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); showOverworld(0);
startTimer(); /* ceasul pornește exact la „Începe aventura" (intro necronometrat) */
voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */
};
@@ -2321,6 +2369,7 @@ buildDots();
showFinale(); return;
}
owResetPlayer(); showOverworld(resumeIdx);
startTimer(); /* resume → reia ceasul de la deadline-ul absolut salvat */
})();
<\/script>
</body>