Campanie multi-stil — PR1 (T1-T8 + TD1-TD6)
Adauga al 6-lea stil de joc: campanie multi-stil care leaga puzzle-urile
in camere de stiluri diferite (clasic/terminal/arcade/chat/point in rotatie),
conectate prin coridoare cu usa, litera si stele.
Contract de montare (verificat la gate T1):
- gameCampaign: un <iframe srcdoc> per camera; camerele cheama parent.*
pe un nivel (merge si pe file://); template per stil cu sentinel __CFG__
injectat prin replace-functie (D1) + json.replace(/</g,'<') (D6)
- roomReady/roomError + timeout 4s -> skip cu 0 stele + cod eroare;
idx detinut de parinte, accepta nextRoom doar de la contentWindow activ (D5)
- parent.beep in mod campanie (un singur AudioContext, D2)
- resume prin safeStore try/catch (D3) + cheie djb2 peste CFG embedat (D11)
Builder:
- selector de stil per puzzle ("Auto (stil)") + optiunea Campanie multi-stil
- normalizePuzzle() la load + import (sursa unica pt forma puzzle, D8)
- blocare export+preview la 0 puzzle-uri; persist() guarded (D12)
- letter normalizat [A-Za-z0-9] + esc la SVG point (D13)
Design (DESIGN.md): tokens --c-*, intro poster, coridor "usa ca erou",
chrome unica sursa de progres, 5 usi CSS/SVG (normal/stuck/crescendo),
mod camera per motor, buget vertical mobil, baseline a11y.
Tooling: tests/smoke.mjs (Playwright, zero-dependente prin npx), TODOS.md,
sectiune ## Testing in CLAUDE.md. Demo-uri regenerate + exemplu-campanie.html.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
playwright.config.mjs
|
||||||
|
test-results/
|
||||||
|
.playwright-mcp/
|
||||||
|
.gstack/
|
||||||
|
scratch/
|
||||||
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Ce este
|
||||||
|
|
||||||
|
Generator de jocuri escape room: un singur fișier HTML (`escape-builder.html`), fără backend, fără build, fără dependențe. Același set de puzzle-uri se exportă în 5 stiluri de joc diferite (clasic/quiz, terminal retro, arcade pixel, story chat, point-and-click).
|
||||||
|
|
||||||
|
## Dezvoltare
|
||||||
|
|
||||||
|
Nu există build sau lint. Produsul (HTML files) e vanilla HTML/CSS/JS, zero-dependențe.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificare: deschide direct în browser (merge de pe file://)
|
||||||
|
# sau servește local:
|
||||||
|
python3 -m http.server 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Testarea manuală: deschide `escape-builder.html`, schimbă "Stil joc" și verifică preview-ul live (iframe), apoi exportă și verifică jocul standalone. Testele automate → vezi `## Testing`.
|
||||||
|
|
||||||
|
## Arhitectură
|
||||||
|
|
||||||
|
Toată aplicația trăiește în `escape-builder.html`, organizată în secțiuni comentate (`stare`, `editor`, `preview`, `template-urile jocului exportat`):
|
||||||
|
|
||||||
|
- **Stare**: obiectul `state` (titlu, poveste, culoare, `style`, listă `puzzles`), persistat automat în `localStorage` sub cheia `escape-builder-v1`. Export/import ca JSON.
|
||||||
|
- **Editor** (stânga): formularele scriu direct în `state` via `data-g`; orice modificare cheamă `onChange()` → persist + refresh preview cu debounce 400ms.
|
||||||
|
- **Preview** (dreapta): `refreshPreview()` setează `iframe.srcdoc = gameHTML(cleanState())` — preview-ul este exact jocul exportat, jucabil.
|
||||||
|
- **Generare joc**: `gameHTML(cfg)` face dispatch pe `cfg.style` către cele 5 motoare: `gameClassic`, `gameTerminal`, `gameArcade`, `gameChat`, `gamePoint`. Fiecare motor returnează un string HTML complet, standalone (jocul exportat merge offline).
|
||||||
|
|
||||||
|
Cod partajat între motoare (injectat în HTML-ul generat):
|
||||||
|
- `libJS(cfg)`: `CFG` (config serializat), `norm` (normalizare răspuns: fără diacritice/majuscule, virgulă→punct), `checkAnswer`, `starsFor` (3/2/1 stele după încercări/indiciu), `finalWord` (literele puzzle-urilor formează cuvântul final), `beep` (WebAudio), `confetti`.
|
||||||
|
- `SNIP.*`: fragmente de template partajate — `baseCss`, modal de întrebare (`modalCss/Html/Js`, folosit de arcade și point) și ecranul final (`finalCss/Html/Js`).
|
||||||
|
|
||||||
|
Tipuri de puzzle: `free` (răspuns liber), `tf` (adevărat/fals), `choice` (variante pe linii separate în `choices`, cea corectă prefixată cu `*`).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Harness Playwright în `tests/smoke.mjs`. Instalare o singură dată:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # instalează @playwright/test (devDependency)
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Rulare:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:regresie # regresie — exemplu-*.html rezolvate până la final + edge cases
|
||||||
|
npm run test:campanie # campanie E2E — rulează după ce integrator anunță gata
|
||||||
|
npm test # suita completă (regresie + campanie)
|
||||||
|
|
||||||
|
# sau direct:
|
||||||
|
npx playwright test tests/smoke.mjs --grep "@regresie"
|
||||||
|
npx playwright test tests/smoke.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Baseline curent (pre-campanie):** 13/13 `@regresie` trec. Testele `@campanie` sunt marcate `skip` — se activează după implementarea `gameCampaign`.
|
||||||
|
|
||||||
|
## Atenție la editare
|
||||||
|
|
||||||
|
- Motoarele de joc sunt template literals mari — backslash-urile din codul generat trebuie dublate (`\\u0300`, `\\n`), iar codul generat folosește `var`/`function` clasic intenționat.
|
||||||
|
- O schimbare în `libJS`/`SNIP` afectează toate cele 5 motoare; verifică fiecare stil în preview.
|
||||||
|
- `exemplu-*.html` sunt jocuri demo exportate din builder (câte unul per stil). Nu le edita manual — după modificări la motoare, regenerează-le prin exportul din builder.
|
||||||
|
- `index.html` e doar pagina de landing care leagă builder-ul și demo-urile.
|
||||||
|
- `package.json` + `node_modules/` sunt **doar dev tooling** (Playwright). Produsul (fișierele HTML) rămâne zero-dependențe — merge offline de pe `file://`.
|
||||||
342
DESIGN.md
Normal file
342
DESIGN.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# DESIGN.md — Escape Room Builder: Campania Multi-Stil
|
||||||
|
|
||||||
|
Contract de design pentru PR1 (Etapa 1 + Tooling). Aprobat la design review 2026-06-12 (scor 6/10 → 9/10).
|
||||||
|
Integratorul consultă acest fișier ca sursă unică de adevăr pentru toate deciziile vizuale și de interacțiune.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Tokens de design
|
||||||
|
|
||||||
|
Orchestratorul declară o dată în `:root` al `gameCampaign`. **Toate suprafețele de campanie consumă DOAR aceste variabile** — nu hardcodezi culori în coridor sau diplomă.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--c-bg: #0d0620; /* fundal global campanie */
|
||||||
|
--c-surface: #221440; /* carduri, panouri */
|
||||||
|
--c-accent: <cfg.color>; /* accentul creatorului — suprascris la runtime */
|
||||||
|
--c-gold: #fbbf24; /* stele, crescendo, reward */
|
||||||
|
--c-line: rgba(255,255,255,.18);/* borduri subtile */
|
||||||
|
--c-ink: #fff; /* text principal */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note intenționate (nu se „repară"):**
|
||||||
|
- Violetul default (`#6d28d9`) și `system-ui` sunt deliberate — accentul aparține creatorului via `cfg.color`; webfonturile sunt imposibile pe `file://`.
|
||||||
|
- `@media print` suprascrie tokens pentru diplomă (fundal alb, borduri `--c-accent`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Intro — Scenă-poster (§Design pct. 1)
|
||||||
|
|
||||||
|
**Structură (de sus în jos):**
|
||||||
|
1. Titlul jocului — cel mai mare element (`font-size: clamp(28px,6vw,48px)`, `font-weight: 900`)
|
||||||
|
2. „Salut, {nume}!" + povestea — secundar, `max-width: 60ch`, `color: rgba(255,255,255,.8)`
|
||||||
|
3. Promisiunea: „{N} camere · {S} stiluri · 1 cuvânt magic" — `font-size: 13px`, `color: rgba(255,255,255,.5)`
|
||||||
|
4. UN singur buton „Începe aventura" — full-width, `background: var(--c-accent)`
|
||||||
|
|
||||||
|
**Reguli:**
|
||||||
|
- Intro necronometrat (timer-ul din E2 pornește la click „Începe aventura").
|
||||||
|
- Fundalul folosește `--c-bg`; limbajul cardului final existent (fundal închis, accent creator).
|
||||||
|
- Povestea întreagă se spune O singură dată, aici. Camerele individuale NU repetă povestea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Coridor — Ierarhie vizuală (§Design pct. 2 + mockup B aprobat)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ CHROME (48px / 40px mobil) │ ← unica sursă de progres global
|
||||||
|
│ ● ● ● ● ● (puncte camere) 0:00 │
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ litera câștigată │ ← strip compact: recompensa camerei
|
||||||
|
│ │ ★★★ + L │ stele camerei │
|
||||||
|
│ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ╔═══════════╗ │ ← ușa DOMINĂ ecranul (estetica stilului următor)
|
||||||
|
│ ║ UȘADOOR ║ │ min-height: 40% din viewport
|
||||||
|
│ ╚═══════════╝ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────┐ │ ← buton full-width, activ IMEDIAT
|
||||||
|
│ │ Deschide ușa → │ │
|
||||||
|
│ └──────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reguli:**
|
||||||
|
- Strip-ul (litera + stele) = **doar recompensa camerei curente**.
|
||||||
|
- Ușa este elementul erou — nu pune text lung lângă ea.
|
||||||
|
- Butonul „Deschide ușa" este activ **imediat** la montarea coridorului. Copilul deține ritmul.
|
||||||
|
- Butonul se dezactivează după primul click (idempotență, §T4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Progres — Un singur owner (§Design pct. 3)
|
||||||
|
|
||||||
|
**Bara chrome = UNICA sursă de progres global.**
|
||||||
|
|
||||||
|
```
|
||||||
|
● ● ● ◉ ○ (puncte: parcurse=--c-gold, curentă=--c-accent, viitoare=stinse)
|
||||||
|
aria-label="Camera 3 din 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Motoarele individuale **nu randează** progres de campanie (nu arată numărul camerei sau câte mai sunt).
|
||||||
|
- Strip-ul coridorului arată **doar** recompensa camerei (litera + stele). Nu total.
|
||||||
|
- `aria-label` obligatoriu pe containerul cu puncte (§A11y pct. 13).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tranziția = Ușa (§Design pct. 4)
|
||||||
|
|
||||||
|
**Flow la click „Deschide ușa":**
|
||||||
|
|
||||||
|
```
|
||||||
|
click buton
|
||||||
|
→ ușa primește .opening → animație scale+fade ~250ms (transform-origin: left center)
|
||||||
|
→ coridorul rămâne pe ecran până la roomReady
|
||||||
|
→ [>1.5s fără roomReady] → puncte de încărcare discrete PE ușă (nu modal separat)
|
||||||
|
→ [4s fără roomReady] → skip automat (→ §5 Skip in-fiction)
|
||||||
|
→ roomReady primit → fade-in camera ~200ms (zero flash)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSS animație deschidere (din `scratch/doors.html`):**
|
||||||
|
```css
|
||||||
|
@keyframes door-open {
|
||||||
|
0% { transform: scale(1) rotateY(0deg); opacity: 1; }
|
||||||
|
50% { transform: scale(1.06) rotateY(-30deg); opacity: .8; }
|
||||||
|
100% { transform: scale(.85) rotateY(-90deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
.door-STIL.opening {
|
||||||
|
animation: door-open .25s cubic-bezier(.4,0,1,1) forwards;
|
||||||
|
transform-origin: left center; perspective: 600px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`prefers-reduced-motion`:** `.opening { animation: none; opacity: 0; }` — stările finale apar direct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Skip In-Fiction — Ușa Înțepenită (§Design pct. 5)
|
||||||
|
|
||||||
|
**Camera moartă = aceeași compoziție de coridor cu ușa înțepenită.**
|
||||||
|
|
||||||
|
Aceeași structură ca §3, dar:
|
||||||
|
- Ușa primește clasa `.stuck` (grayscale + brightness 0.6) + badge `.door-lock` (🔒)
|
||||||
|
- Titlu: **„Ușa asta e înțepenită!"** (nu „Eroare" sau text tehnic)
|
||||||
|
- Buton primar: **„Sari la camera următoare"**
|
||||||
|
- Cod tehnic (stil·idx) — monospace mic, `color: rgba(255,255,255,.3)` — jos, fotografiabil
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Structura stuck state -->
|
||||||
|
<div class="corridor-stuck">
|
||||||
|
<div class="door-STIL stuck">
|
||||||
|
<span class="door-lock">🔒</span>
|
||||||
|
</div>
|
||||||
|
<h2>Ușa asta e înțepenită!</h2>
|
||||||
|
<button class="btn-primary">Sari la camera următoare</button>
|
||||||
|
<code class="err-code">terminal·2</code> <!-- stil·idx -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**`roomError` post-ready** → același ecran (același handler, aceeași UI).
|
||||||
|
**Pe final/diplomă** → camera sărită = ușă înțepenită + 0 stele + dală goală (fără literă).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ritm + Crescendo (§Design pct. 6)
|
||||||
|
|
||||||
|
- **Buget total animație per coridor: < 1s** (paralel, nu serial)
|
||||||
|
- **Butonul activ IMEDIAT** — copilul nu așteaptă animații
|
||||||
|
- **Ultimul coridor** = crescendo:
|
||||||
|
- Ușa finală mai mare (`.crescendo` class — scale 1.2 + glow per stil)
|
||||||
|
- Text suplimentar: **„Ultima cameră!"** (`font-weight: 700`, `color: var(--c-gold)`)
|
||||||
|
- Buton: „Deschide ultima ușă" (nu just „Deschide ușa")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Cele 5 Uși — Spec CSS/SVG (§Design pct. 7)
|
||||||
|
|
||||||
|
**Fișier de referință:** `scratch/doors.html` — vizualizare live cu toate stările.
|
||||||
|
**3 stări per ușă:** `.normal` (implicit) / `.stuck` (grayscale+lacăt) / `.crescendo` (scale+glow).
|
||||||
|
|
||||||
|
### Door 1 — Classic
|
||||||
|
```
|
||||||
|
Ușă-card albă · colțuri rotunjite (border-radius: 10px) · „?" auriu centrat
|
||||||
|
Crescendo: chenar auriu (box-shadow: 0 0 0 3px var(--c-gold))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Door 2 — Terminal
|
||||||
|
```
|
||||||
|
Dreptunghi negru · ramă verde fosforescent (border: 2px solid #39ff6e, glow 14px)
|
||||||
|
Cursor _ clipind (animation: dt-blink 1s step-end infinite)
|
||||||
|
Scanlines (::before, repeating-linear-gradient)
|
||||||
|
Stuck: border-color: #444, cursor animation: none
|
||||||
|
Crescendo: double-ring + glow intensificat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Door 3 — Arcade
|
||||||
|
```
|
||||||
|
Pixel-art chunky: border: 4px solid #4ade80 + box-shadow: inset 0 0 0 4px #166534, 0 0 0 4px #0a1606
|
||||||
|
Mâner galben pătrat (::after, 10×10px, no border-radius)
|
||||||
|
image-rendering: pixelated
|
||||||
|
Crescendo: outer ring #4ade80 + glow verde
|
||||||
|
```
|
||||||
|
|
||||||
|
### Door 4 — Chat
|
||||||
|
```
|
||||||
|
Siluetă telefon: gradient albastru (#1d4ed8→#1e3a8a), border-radius: 12px
|
||||||
|
Screen dark cu bule de mesaj (NPC: #1e40af stânga, player: #3b82f6 dreapta)
|
||||||
|
Home bar (22px, rgba(255,255,255,.3))
|
||||||
|
Crescendo: box-shadow: 0 0 0 2px #3b82f6 + glow albastru
|
||||||
|
```
|
||||||
|
|
||||||
|
### Door 5 — Point
|
||||||
|
```
|
||||||
|
SVG: ușă de scenă din lemn (#7c4f2c), 2 panouri superioare (rgba(0,0,0,.22))
|
||||||
|
Clanță rotundă (#f3cf6d), lupă (cerc + linie diagonală, stroke: #f3cf6d, width 3)
|
||||||
|
Crescendo: filter drop-shadow(0 0 12px rgba(243,207,109,.6))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Final ≠ Diplomă (§Design pct. 8)
|
||||||
|
|
||||||
|
**Ecranul final celebrează DOAR.** Nu afișează diploma.
|
||||||
|
|
||||||
|
```
|
||||||
|
Evadare reușită!
|
||||||
|
★★★★★ (stele totale)
|
||||||
|
[D] [A] [N] [C] [E] ← cuvântul magic, literă-cu-literă, 0.18s delay
|
||||||
|
Mesajul creatorului
|
||||||
|
[Joacă din nou] [Vezi diploma →] ← buton secundar „Vezi diploma" (Etapa 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Camera sărită** = dală goală cu 🔒 (fără literă, fără animație flip).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Diplomă — Certificat A4 Print-First (§Design pct. 9 — Etapa 2 / PR2)
|
||||||
|
|
||||||
|
> Implementare în T10/PR2. Spec inclusă aici ca parte a contractului de design.
|
||||||
|
|
||||||
|
- **Format:** A4 portret, fundal ALB, chenar dublu `var(--c-accent)`
|
||||||
|
- **Titlu:** „DIPLOMĂ DE EVADARE" — **singurul** element cu font serif (limbajul certificatelor)
|
||||||
|
- **Ierarhie:** Numele copilului = cel mai mare element pe pagină
|
||||||
|
- **Rând de stele per cameră** — camerele sărite = 🔒
|
||||||
|
- **Cuvântul magic** în dăle, aceeași iconografie ca finalul
|
||||||
|
- **Footer:** dată + „creat de {creator}" + marcaj discret „timpul a expirat" (dacă e cazul)
|
||||||
|
- **`@media print`:** doar diploma, `margin: 20mm`, `print-color-adjust: exact` pe accente, `background: white`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2)
|
||||||
|
|
||||||
|
> Implementare în T10/PR2.
|
||||||
|
|
||||||
|
- Pornește **exact** la click „Începe aventura" (intro necronometrat)
|
||||||
|
- Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`)
|
||||||
|
- **Sub 1 minut** → devine auriu (`color: var(--c-gold)`) — NU roșu pulsant (public copii)
|
||||||
|
- La **expirare** → îngheață pe `0:00` + marcaj text discret; jocul curge nestingherit (zero penalizare, stelele rămân)
|
||||||
|
- **Deadline absolut** în `sessionStorage` — resume-ul nu resetează ceasul
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Buget Vertical Mobil (§Design pct. 11)
|
||||||
|
|
||||||
|
```
|
||||||
|
Chrome: 48px desktop / 40px la max-width: 600px (doar puncte + timer, fără etichete)
|
||||||
|
Camerele: height = calc(100dvh - <chrome-height>) (dvh pentru mobile address bar)
|
||||||
|
Arcade: max-height pe canvas → scalare automată să încapă în viewport
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assert obligatoriu în `tests/smoke.mjs`:**
|
||||||
|
```js
|
||||||
|
// La viewport 320×568, niciun element nu depășește documentElement.scrollWidth
|
||||||
|
assert(document.documentElement.scrollWidth <= 320, 'overflow orizontal 320px')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reguli:**
|
||||||
|
- ZERO scroll orizontal la 320×568 (iPhone SE)
|
||||||
|
- Fără `overflow-x: auto` pe coridor sau cameră — rezolvă problema, nu o ascunde
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Mod Cameră — Regulă Funcțională (§Design pct. 12 — închide D14)
|
||||||
|
|
||||||
|
**În campanie, fiecare motor ascunde:** (a) titlul jocului, (b) progresul global propriu, (c) restart-ul propriu.
|
||||||
|
**Părintele deține** toate acestea (bara chrome). Motoarele **păstrează** HUD-ul de gameplay și identitatea stilului.
|
||||||
|
|
||||||
|
| Motor | Ascunde (în campanie) | Păstrează |
|
||||||
|
|----------|---------------------------------|----------------------------------|
|
||||||
|
| Classic | `h1` + bara de progres + contor | Întrebări, stele per puzzle |
|
||||||
|
| Terminal | Titlul mare (linia banner) | Antetul de 1 linie, comenzi |
|
||||||
|
| Arcade | `h1` | HUD chei/stele cameră, canvas |
|
||||||
|
| Chat | Subtitlul jocului | Header-ul personajului, bule |
|
||||||
|
| Point | `h1` | Hint-ul de scenă, obiectele SVG |
|
||||||
|
|
||||||
|
**Implementare:** când `CFG._campaign` există, motorul adaugă clasa `.campaign-mode` pe `body`.
|
||||||
|
CSS: `.campaign-mode h1, .campaign-mode .game-progress { display: none; }` (specific per motor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. A11y Baseline — Suprafețe Noi (§Design pct. 13)
|
||||||
|
|
||||||
|
Aplicabil pe toate suprafețele adăugate în PR1 (coridor, chrome, intro, skip, final).
|
||||||
|
**Nu este audit al motoarelor existente** — acela e în `TODOS.md`.
|
||||||
|
|
||||||
|
| Criteriu | Specificație |
|
||||||
|
|----------|-------------|
|
||||||
|
| Ținte tap | Minim 44×44px (buton „Deschide ușa", puncte chrome, buton skip) |
|
||||||
|
| Contrast text | `rgba(255,255,255,.7)` pe `--c-bg` ≥ 4.5:1 — text secundar |
|
||||||
|
| Focus | Buton „Deschide ușa" focusabil + activabil cu Enter |
|
||||||
|
| ARIA | `aria-label="Camera X din Y"` pe containerul punctelor de progres |
|
||||||
|
| Motion | `@media (prefers-reduced-motion: reduce)` dezactivează: confetti, flipin, animația ușii — stările finale apar direct |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Tabel Stări per Suprafață
|
||||||
|
|
||||||
|
| Suprafață | Loading | Empty | Error | Success | Partial |
|
||||||
|
|-----------|---------|-------|-------|---------|---------|
|
||||||
|
| Coridor → cameră | ușa .opening; puncte pe ușă >1.5s | — | ușă .stuck + skip (§6) | fade-in cameră 200ms | — |
|
||||||
|
| Builder (campanie) | — | 0 puzzle: mesaj ghidant, export blocat | stil invalid: avertisment + rotație | preview rulează | — |
|
||||||
|
| Resume | — | — | hash invalid → restart silențios de la intro | coridorul camerei curente | refresh mid-campanie → coridor |
|
||||||
|
| Final | — | — | camere sărite: 🔒 + dală goală | stele + cuvânt + confetti | cuvânt cu dăle-lacăt |
|
||||||
|
| Skip camera | — | — | roomError (oricând) → ecran stuck | — | 0 stele, fără literă, cod eroare |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Storyboard Emoțional
|
||||||
|
|
||||||
|
| Pas | Copilul | Simte | Susținut de |
|
||||||
|
|-----|---------|-------|-------------|
|
||||||
|
| Intro | vede titlul + promisiunea | curiozitate | §2 (poster) |
|
||||||
|
| Camera 1 | joacă primul stil | concentrare | §12 (mod cameră discret), chrome neutru |
|
||||||
|
| Coridor 1 | primește litera, vede ușa nouă | mândrie + anticipare | §3, §5, §8 |
|
||||||
|
| Camerele 2..N-1 | stil nou de fiecare dată | varietate, „ce urmează?" | rotație + ritual <1s (§7) |
|
||||||
|
| Ultimul coridor | ușa mai mare, „Ultima cameră!" | crescendo | §7 (crescendo) |
|
||||||
|
| Final | cuvântul magic se dezvăluie | triumf | §9 (final celebrare pură) |
|
||||||
|
| Diplomă (E2) | o printează cu părintele | mândrie fizică | §10 (A4 print-first) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. NOT in Scope (cu motiv)
|
||||||
|
|
||||||
|
| Funcție | Motiv excludere |
|
||||||
|
|---------|-----------------|
|
||||||
|
| Audit a11y motoare existente | Post-PR1, sub harness Playwright — în `TODOS.md` |
|
||||||
|
| Timer implementare | PR2/Etapa 2 (T10) |
|
||||||
|
| Diplomă implementare | PR2/Etapa 2 (T10) |
|
||||||
|
| Auto-advance la coridor | Respins (6C): agenția copilului primează la momentul-recompensă |
|
||||||
|
| Overlay tehnic de skip | Respins (5B): eșecul rămâne în ficțiune (ușă înțepenită) |
|
||||||
|
| Diploma pe ecranul final | Respins (3B): one job per section |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Fișiere de Referință
|
||||||
|
|
||||||
|
| Artefact | Locație | Conținut |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| Uși (cod) | `scratch/doors.html` | 5 uși CSS/SVG × 3 stări, animație deschidere, tokens |
|
||||||
|
| Mockup aprobat | `~/.gstack/projects/romfast-escape-builder/designs/coridor-campanie-20260612/` | wireframes.html, coridor-3-variante.png (varianta B), approved.json |
|
||||||
|
| Plan complet | `~/.claude/plans/home-claude-claude-plans-propune-alte-u-replicated-papert.md` | §Design, task-uri, review reports |
|
||||||
65
TODOS.md
Normal file
65
TODOS.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# TODOS — Escape Room Builder
|
||||||
|
|
||||||
|
Backlog post-PR1 și note tehnice pentru iterațiile viitoare.
|
||||||
|
Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2026-06-12-campania-multi-stil.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-PR1 (după ship-ul campaniei)
|
||||||
|
|
||||||
|
### Muzică accelerată la timer (PR2 / T10)
|
||||||
|
- Audio ambient în campanie: track calm → accelerare progresivă sub 1 minut.
|
||||||
|
- Ownership: părintele deține AudioContext; camerele nu știu de muzică.
|
||||||
|
- Fallback: zero pedeapsă dacă AudioContext lipsă (webview restricitve).
|
||||||
|
- 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).
|
||||||
|
|
||||||
|
### Edge case-uri voce (SpeechSynthesis) — PR2
|
||||||
|
- `speechSynthesis.getVoices()` poate fi gol sincron → ascultă `voiceschanged`.
|
||||||
|
- Fără voce `ro-*` → fallback la vocea default (nu crash, nu tăcere).
|
||||||
|
- Voce activă mid-cameră → `speechSynthesis.cancel()` la demontare cameră (pater deține).
|
||||||
|
- `parent.voiceSay(text)` = no-op în jocurile simple (funcția nu există) → guard `typeof parent.voiceSay === 'function'`.
|
||||||
|
- Referință: D10 din plan; E2 Etapa 2 pct. 3.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
- Migrarea pe SNIP.finalJs deblochează ramura `_campaign` uniformă pentru toate cele 5 motoare.
|
||||||
|
- Prim pas al Etapei 2 (D7): migrarea `gameClassic` pe `libJS+SNIP` → regresie manuală pe classic.
|
||||||
|
- Referință: planul §Etapa 2 pct. 1; D7.
|
||||||
|
|
||||||
|
### Audit a11y motoare existente (post-PR1, sub harness Playwright)
|
||||||
|
- **Ținte tap ≥ 44px**: dpad arcade, butoane tf/choice, butonul "Trimite" din chat.
|
||||||
|
- **Contrast ≥ 4.5:1**: text terminal dim (`#1f9c4a` pe `#04130a` — verifică), hint text clasic.
|
||||||
|
- **`@media (prefers-reduced-motion: reduce)`**: dezactivează `pop`, `flip`, `flipin`, `shake`, `confetti`, `bin` — stările finale apar direct.
|
||||||
|
- **Focus & Enter**: "Deschide ușa" (campanie) focusabil + Enter; dpad arcade accesibil cu keyboard.
|
||||||
|
- **`aria-label` pe progres**: bara chrome din campanie (`aria-label="Camera X din Y"`).
|
||||||
|
- Referință: §Design pct. 13 (TD5, PR2); D19 din plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Iterația 2 — Adventure Mode v0
|
||||||
|
*(decizie office-hours: fundația contractului de azi e infrastructura directă)*
|
||||||
|
|
||||||
|
- Contract de montare (`nextRoom`, `roomReady`, `roomError`) se refolosesc as-is.
|
||||||
|
- Motoarele noi (orice stil) implementează aceleași 3 puncte + `parent.beep`.
|
||||||
|
- `gameCampaign` se extinde cu ramificare: `if (answer === 'left') nextRoom({dir: 'left'})`.
|
||||||
|
- Builder UI: adaugă câmpul "ramificare" per puzzle; drag & drop între camere.
|
||||||
|
- Referință: design doc §NOT in scope "Adventure Mode v0".
|
||||||
|
|
||||||
|
## Iterația 3 — Joc-în-URL + QR
|
||||||
|
*(depinde de măsurarea dimensiunii JSON comprimate)*
|
||||||
|
|
||||||
|
- `gameHTML(cfg)` → URL data: sau LZW/gzip → QR code printabil.
|
||||||
|
- Open Question 2 din design doc: câte puzzle-uri încap în 2KB (URL QR L)?
|
||||||
|
- Alternative: GitHub Pages export automat; sau link scurt cu backend minimal.
|
||||||
|
- Referință: design doc §NOT in scope "Joc-în-URL + QR".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known improvements (oricând)
|
||||||
|
|
||||||
|
- **`updateHud` duplicat**: arcade linia 1003 și point linia 1283 au funcții identice → consolidat în `SNIP` (T8 din plan, igienă PR1).
|
||||||
|
- **`persist()` fără try/catch**: builder-ul poate crăpa pe storage plin → guard (D12, T8).
|
||||||
|
- **`esc(L)` la inserția innerHTML din point** (:1274): un `<` în câmpul `letter` strică scena SVG (D13, T8).
|
||||||
|
- **Validare 0 puzzle-uri**: export și preview blocate cu mesaj ghidant (T5).
|
||||||
|
- **Stil invalid la import JSON**: avertisment în builder + rotație automată (T5, D8).
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
<option value="arcade">Arcade pixel</option>
|
<option value="arcade">Arcade pixel</option>
|
||||||
<option value="chat">Story chat</option>
|
<option value="chat">Story chat</option>
|
||||||
<option value="point">Point-and-click</option>
|
<option value="point">Point-and-click</option>
|
||||||
|
<option value="campaign">Campanie multi-stil</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -160,6 +161,9 @@
|
|||||||
|
|
||||||
const STORAGE_KEY = 'escape-builder-v1';
|
const STORAGE_KEY = 'escape-builder-v1';
|
||||||
|
|
||||||
|
const CAMPAIGN_ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
const CAMPAIGN_STYLE_NAMES = { classic: 'Clasic', terminal: 'Terminal Retro', arcade: 'Arcade Pixel', chat: 'Story Chat', point: 'Point-and-Click' };
|
||||||
|
|
||||||
const defaultState = () => ({
|
const defaultState = () => ({
|
||||||
title: 'Comoara ascunsa',
|
title: 'Comoara ascunsa',
|
||||||
player: '',
|
player: '',
|
||||||
@@ -175,15 +179,32 @@ const defaultState = () => ({
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
const blankPuzzle = () => ({ title: '', type: 'free', question: '', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: '' });
|
function normalizePuzzle(p) {
|
||||||
|
const validTypes = ['free', 'tf', 'choice'];
|
||||||
|
const validStyles = ['', 'classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
if (typeof p.title !== 'string') p.title = '';
|
||||||
|
if (!validTypes.includes(p.type)) p.type = 'free';
|
||||||
|
if (typeof p.question !== 'string') p.question = '';
|
||||||
|
if (typeof p.answer !== 'string') p.answer = '';
|
||||||
|
if (p.tfAnswer !== 'Adevarat' && p.tfAnswer !== 'Fals') p.tfAnswer = 'Adevarat';
|
||||||
|
if (typeof p.choices !== 'string') p.choices = '';
|
||||||
|
if (typeof p.hint !== 'string') p.hint = '';
|
||||||
|
if (typeof p.letter !== 'string') p.letter = '';
|
||||||
|
if (!validStyles.includes(p.style || '')) p.style = '';
|
||||||
|
if (typeof p.style === 'undefined') p.style = '';
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blankPuzzle = () => normalizePuzzle({ title: '', type: 'free', question: '', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: '', style: '' });
|
||||||
|
|
||||||
let state = Object.assign(defaultState(), load() || {});
|
let state = Object.assign(defaultState(), load() || {});
|
||||||
|
if (Array.isArray(state.puzzles)) state.puzzles = state.puzzles.map(normalizePuzzle);
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) { return null; }
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) { return null; }
|
||||||
}
|
}
|
||||||
function persist() {
|
function persist() {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { /* quota/private mode — continuă fără autosave (D12) */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- editor ---------- */
|
/* ---------- editor ---------- */
|
||||||
@@ -227,7 +248,20 @@ function puzzleCard(p, i) {
|
|||||||
<option value="choice" ${p.type === 'choice' ? 'selected' : ''}>Variante</option>
|
<option value="choice" ${p.type === 'choice' ? 'selected' : ''}>Variante</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
${state.style === 'campaign' ? `
|
||||||
|
<div class="narrow">
|
||||||
|
<label>Stil cameră</label>
|
||||||
|
<select data-f="style">
|
||||||
|
<option value="" ${!p.style ? 'selected' : ''}>Auto (${esc(CAMPAIGN_STYLE_NAMES[CAMPAIGN_ROTATION[i % CAMPAIGN_ROTATION.length]])})</option>
|
||||||
|
<option value="classic" ${p.style === 'classic' ? 'selected' : ''}>Clasic</option>
|
||||||
|
<option value="terminal" ${p.style === 'terminal' ? 'selected' : ''}>Terminal Retro</option>
|
||||||
|
<option value="arcade" ${p.style === 'arcade' ? 'selected' : ''}>Arcade Pixel</option>
|
||||||
|
<option value="chat" ${p.style === 'chat' ? 'selected' : ''}>Story Chat</option>
|
||||||
|
<option value="point" ${p.style === 'point' ? 'selected' : ''}>Point-and-Click</option>
|
||||||
|
</select>
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${state.style === 'campaign' && p.style && !CAMPAIGN_STYLE_NAMES[p.style] ? `<div class="help" style="color:#f87171">Stil necunoscut "${esc(p.style)}" — se foloseste Auto</div>` : ''}
|
||||||
<label>Intrebarea</label>
|
<label>Intrebarea</label>
|
||||||
<textarea data-f="question" rows="2">${esc(p.question)}</textarea>
|
<textarea data-f="question" rows="2">${esc(p.question)}</textarea>
|
||||||
${p.type === 'free' ? `
|
${p.type === 'free' ? `
|
||||||
@@ -269,7 +303,11 @@ function esc(s) {
|
|||||||
/* evenimente editor */
|
/* evenimente editor */
|
||||||
|
|
||||||
document.querySelectorAll('[data-g]').forEach(el => {
|
document.querySelectorAll('[data-g]').forEach(el => {
|
||||||
el.addEventListener('input', () => { state[el.dataset.g] = el.value; onChange(); });
|
el.addEventListener('input', () => {
|
||||||
|
state[el.dataset.g] = el.value;
|
||||||
|
if (el.dataset.g === 'style') renderPuzzles(); /* re-render: style selector per card apare/dispare */
|
||||||
|
onChange();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
puzzleList.addEventListener('input', e => {
|
puzzleList.addEventListener('input', e => {
|
||||||
@@ -336,6 +374,7 @@ $('#fileLoad').addEventListener('change', e => {
|
|||||||
const data = JSON.parse(txt);
|
const data = JSON.parse(txt);
|
||||||
if (!Array.isArray(data.puzzles)) throw new Error('format');
|
if (!Array.isArray(data.puzzles)) throw new Error('format');
|
||||||
state = Object.assign(defaultState(), data);
|
state = Object.assign(defaultState(), data);
|
||||||
|
state.puzzles = state.puzzles.map(normalizePuzzle);
|
||||||
renderGlobals(); renderPuzzles(); onChange();
|
renderGlobals(); renderPuzzles(); onChange();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Fisierul nu este un proiect valid de escape room.');
|
alert('Fisierul nu este un proiect valid de escape room.');
|
||||||
@@ -345,6 +384,7 @@ $('#fileLoad').addEventListener('change', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#btnExport').addEventListener('click', () => {
|
$('#btnExport').addEventListener('click', () => {
|
||||||
|
if (state.puzzles.length === 0) { alert('Adauga cel putin un puzzle inainte de export!'); return; }
|
||||||
download(slug(state.title) + '.html', gameHTML(cleanState()), 'text/html');
|
download(slug(state.title) + '.html', gameHTML(cleanState()), 'text/html');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,7 +392,11 @@ $('#btnReload').addEventListener('click', refreshPreview);
|
|||||||
|
|
||||||
function cleanState() {
|
function cleanState() {
|
||||||
const s = JSON.parse(JSON.stringify(state));
|
const s = JSON.parse(JSON.stringify(state));
|
||||||
s.puzzles.forEach(p => delete p._closed);
|
s.puzzles.forEach(p => {
|
||||||
|
delete p._closed;
|
||||||
|
/* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */
|
||||||
|
p.letter = (p.letter || '').replace(/[^A-Za-z0-9]/g, '').charAt(0);
|
||||||
|
});
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,18 +423,25 @@ function onChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshPreview() {
|
function refreshPreview() {
|
||||||
$('#frame').srcdoc = gameHTML(cleanState());
|
if (state.puzzles.length === 0) {
|
||||||
|
$('#frame').srcdoc = '<html><body style="font:system-ui,sans-serif;color:#fff;background:#0d0620;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;padding:20px"><div><div style="font-size:48px;margin-bottom:16px">🚪</div><p style="opacity:.7;line-height:1.6;margin:0">Adaugă cel puțin un puzzle<br>ca să vezi preview-ul.</p></div></body></html>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewCfg = cleanState();
|
||||||
|
if (previewCfg.style === 'campaign') previewCfg._noResume = true; /* preview nu reia niciodată (D3) */
|
||||||
|
$('#frame').srcdoc = gameHTML(previewCfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- template-urile jocului exportat ---------- */
|
/* ---------- template-urile jocului exportat ---------- */
|
||||||
|
|
||||||
function gameHTML(cfg) {
|
function gameHTML(cfg) {
|
||||||
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint };
|
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint, campaign: gameCampaign };
|
||||||
return (engines[cfg.style] || gameClassic)(cfg);
|
return (engines[cfg.style] || gameClassic)(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function gameClassic(cfg) {
|
function gameClassic(cfg) {
|
||||||
const json = JSON.stringify(cfg).replace(/</g, '\\u003c');
|
/* cfg === '__TEMPLATE__' → emit sentinel __CFG__ în loc de JSON (D1) */
|
||||||
|
const json = (cfg === '__TEMPLATE__') ? '__CFG__' : JSON.stringify(cfg).replace(/</g, '\\u003c');
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="ro">
|
<html lang="ro">
|
||||||
<head>
|
<head>
|
||||||
@@ -609,6 +660,11 @@ function check(given, expected) {
|
|||||||
function next() {
|
function next() {
|
||||||
idx++;
|
idx++;
|
||||||
if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
|
if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
|
||||||
|
if(CFG._campaign){
|
||||||
|
var L = ''; for(var ci=0;ci<CFG.puzzles.length;ci++){var lc=(CFG.puzzles[ci].letter||'').trim();if(lc)L+=lc.toUpperCase();}
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L.charAt(0)}); }catch(e){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
show('sFinal');
|
show('sFinal');
|
||||||
var max = CFG.puzzles.length * 3;
|
var max = CFG.puzzles.length * 3;
|
||||||
el('finalStars').textContent = totalStars + ' / ' + max + ' \\u2605';
|
el('finalStars').textContent = totalStars + ' / ' + max + ' \\u2605';
|
||||||
@@ -644,6 +700,7 @@ function confetti() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beep(ok) {
|
function beep(ok) {
|
||||||
|
if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; }
|
||||||
try {
|
try {
|
||||||
var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)());
|
var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)());
|
||||||
var t = ctx.currentTime;
|
var t = ctx.currentTime;
|
||||||
@@ -658,6 +715,8 @@ function beep(ok) {
|
|||||||
});
|
});
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} }
|
||||||
<\/script>
|
<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
@@ -666,7 +725,8 @@ function beep(ok) {
|
|||||||
/* ---------- biblioteca comuna pentru motoarele de joc ---------- */
|
/* ---------- biblioteca comuna pentru motoarele de joc ---------- */
|
||||||
|
|
||||||
function libJS(cfg) {
|
function libJS(cfg) {
|
||||||
const json = JSON.stringify(cfg).replace(/</g, '\\u003c');
|
/* cfg === '__TEMPLATE__' → emit sentinel __CFG__ în loc de JSON real (D1) */
|
||||||
|
const json = (cfg === '__TEMPLATE__') ? '__CFG__' : JSON.stringify(cfg).replace(/</g, '\\u003c');
|
||||||
return `var CFG = ${json};
|
return `var CFG = ${json};
|
||||||
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
||||||
var totalStars = 0;
|
var totalStars = 0;
|
||||||
@@ -677,8 +737,16 @@ function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){
|
|||||||
function choiceOpts(p){ return (p.choices || '').split('\\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
function choiceOpts(p){ return (p.choices || '').split('\\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
||||||
function choiceCorrect(p){ var ls = (p.choices || '').split('\\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
function choiceCorrect(p){ var ls = (p.choices || '').split('\\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
||||||
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
||||||
function beep(ok){ try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
||||||
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }`;
|
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
||||||
|
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){
|
||||||
|
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
|
||||||
|
var _cs = document.createElement('style');
|
||||||
|
_cs.textContent = 'h1{display:none!important}.progress{display:none!important}.meta{display:none!important}';
|
||||||
|
(document.head || document.documentElement).appendChild(_cs);
|
||||||
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNIP = {};
|
const SNIP = {};
|
||||||
@@ -776,6 +844,11 @@ SNIP.finalHtml = `<div id="fOverlay"><div class="fcard">
|
|||||||
</div></div>`;
|
</div></div>`;
|
||||||
|
|
||||||
SNIP.finalJs = `function showFinal(){
|
SNIP.finalJs = `function showFinal(){
|
||||||
|
if(CFG._campaign){
|
||||||
|
var L = finalWord().charAt(0);
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L}); }catch(e){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \\u2605';
|
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \\u2605';
|
||||||
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
||||||
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
||||||
@@ -848,7 +921,10 @@ function echo(text, cls){ var d = document.createElement('div'); d.className = '
|
|||||||
function collected(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += solved[i] ? L.toUpperCase() + ' ' : '_ '; } return w.trim() || '(niciuna)'; }
|
function collected(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += solved[i] ? L.toUpperCase() + ' ' : '_ '; } return w.trim() || '(niciuna)'; }
|
||||||
|
|
||||||
var bar = '==============================================';
|
var bar = '==============================================';
|
||||||
say([bar, ' ' + CFG.title.toUpperCase(), bar, ' ', (CFG.player ? CFG.player + ', ' : '') + CFG.story, ' ', 'Comenzi: INDICIU, LITERE, AJUTOR. In rest, scrie raspunsul si apasa Enter.'], '', nextPuzzle);
|
var introLines = CFG._campaign
|
||||||
|
? [bar, ' ' + CFG.title.toUpperCase(), bar, 'Comenzi: INDICIU, AJUTOR. Scrie raspunsul si apasa Enter.']
|
||||||
|
: [bar, ' ' + CFG.title.toUpperCase(), bar, ' ', (CFG.player ? CFG.player + ', ' : '') + CFG.story, ' ', 'Comenzi: INDICIU, LITERE, AJUTOR. In rest, scrie raspunsul si apasa Enter.'];
|
||||||
|
say(introLines, '', nextPuzzle);
|
||||||
|
|
||||||
function nextPuzzle(){
|
function nextPuzzle(){
|
||||||
idx++; attempts = 0; hintUsed = false;
|
idx++; attempts = 0; hintUsed = false;
|
||||||
@@ -862,6 +938,13 @@ function nextPuzzle(){
|
|||||||
|
|
||||||
function finale(){
|
function finale(){
|
||||||
done = true;
|
done = true;
|
||||||
|
if(CFG._campaign){
|
||||||
|
var s = totalStars; var L = finalWord().charAt(0);
|
||||||
|
say(['>> CAMERA REZOLVATA! Stele: ' + s + (L ? ' | Litera: ' + L : '')], 'ok', function(){
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:s, letter:L}); }catch(e){}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
var w = finalWord().split('').join(' ');
|
var w = finalWord().split('').join(' ');
|
||||||
var lines = [' ', bar, ' E V A D A R E R E U S I T A', bar, 'Stele: ' + totalStars + ' / ' + (CFG.puzzles.length * 3)];
|
var lines = [' ', bar, ' E V A D A R E R E U S I T A', bar, 'Stele: ' + totalStars + ' / ' + (CFG.puzzles.length * 3)];
|
||||||
if (w) lines.push('Cuvantul magic: ' + w);
|
if (w) lines.push('Cuvantul magic: ' + w);
|
||||||
@@ -904,6 +987,7 @@ cmd.addEventListener('keydown', function(e){
|
|||||||
say(['>> ACCES RESPINS. Mai incearca.'], 'bad');
|
say(['>> ACCES RESPINS. Mai incearca.'], 'bad');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
roomReady();
|
||||||
<\/script>
|
<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
@@ -1047,6 +1131,7 @@ document.querySelectorAll('#dpad button').forEach(function(b){
|
|||||||
${SNIP.modalJs}
|
${SNIP.modalJs}
|
||||||
${SNIP.finalJs}
|
${SNIP.finalJs}
|
||||||
updateHud(); draw();
|
updateHud(); draw();
|
||||||
|
roomReady();
|
||||||
<\/script>
|
<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
@@ -1182,8 +1267,12 @@ function next(){
|
|||||||
seq([(p.title ? p.title + '. ' : '') + p.question], function(){ setComposer(p); });
|
seq([(p.title ? p.title + '. ' : '') + p.question], function(){ setComposer(p); });
|
||||||
}
|
}
|
||||||
|
|
||||||
seq(['Salut' + (CFG.player ? ', ' + CFG.player : '') + '!'].concat(storyChunks()).concat(['Ma ajuti sa ies de aici?']), next);
|
var chatIntro = CFG._campaign
|
||||||
|
? ['Camera ' + (CFG._campaign.idx + 1) + ' din ' + CFG._campaign.total + '. Sa incepem!']
|
||||||
|
: ['Salut' + (CFG.player ? ', ' + CFG.player : '') + '!'].concat(storyChunks()).concat(['Ma ajuti sa ies de aici?']);
|
||||||
|
seq(chatIntro, next);
|
||||||
${SNIP.finalJs}
|
${SNIP.finalJs}
|
||||||
|
roomReady();
|
||||||
<\/script>
|
<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
@@ -1296,6 +1385,568 @@ function updateHud(){
|
|||||||
${SNIP.modalJs}
|
${SNIP.modalJs}
|
||||||
${SNIP.finalJs}
|
${SNIP.finalJs}
|
||||||
updateHud();
|
updateHud();
|
||||||
|
roomReady();
|
||||||
|
<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- motor: campanie multi-stil ----------
|
||||||
|
*
|
||||||
|
* ASCII DIAGRAM — contractul parent.*:
|
||||||
|
* ┌──────────── orchestrator (window) ─────────────────────────┐
|
||||||
|
* │ nextRoom({idx,stars,letter}) ← camera.parent.nextRoom │
|
||||||
|
* │ roomReady(idx) ← camera.parent.roomReady │
|
||||||
|
* │ roomError(idx,msg) ← camera.parent.roomError │
|
||||||
|
* │ beep(ok) ← camera.parent.beep │
|
||||||
|
* └────────────────────────────────────────────────────────────┘
|
||||||
|
* idx activ deținut de orchestrator; frame stale ignorate (D5).
|
||||||
|
* nextRoom acceptat o singură dată per idx (idempotență, D4).
|
||||||
|
* Timeout 4s fără roomReady → skip cameră (T3).
|
||||||
|
* srcdoc = TPL[stil].replace(TOKEN, fn) — funcție nu string (D1).
|
||||||
|
* json = JSON.stringify(cfg).replace(/</g,'\\u003c') — D6.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function gameCampaign(cfg) {
|
||||||
|
const ROTATION = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
|
||||||
|
/* Template-uri per stil: fiecare motor generat O dată cu sentinel __CFG__ */
|
||||||
|
const stylesNeeded = new Set(cfg.puzzles.map((p, i) => p.style || ROTATION[i % ROTATION.length]));
|
||||||
|
const engines = { classic: gameClassic, terminal: gameTerminal, arcade: gameArcade, chat: gameChat, point: gamePoint };
|
||||||
|
const templates = {};
|
||||||
|
for (const style of stylesNeeded) {
|
||||||
|
templates[style] = (engines[style] || gameClassic)('__TEMPLATE__');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tplJson = JSON.stringify(templates).replace(/</g, '\\u003c');
|
||||||
|
const masterJson = JSON.stringify(cfg).replace(/</g, '\\u003c');
|
||||||
|
const nRooms = cfg.puzzles.length;
|
||||||
|
const nStyles = stylesNeeded.size;
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ro">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${esc(cfg.title)}</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* ASCII DIAGRAM — contractul parent.*:
|
||||||
|
* ┌──────────── orchestrator (window) ─────────────────────────┐
|
||||||
|
* │ nextRoom({idx,stars,letter}) ← camera.parent.nextRoom │
|
||||||
|
* │ roomReady(idx) ← camera.parent.roomReady │
|
||||||
|
* │ roomError(idx,msg) ← camera.parent.roomError │
|
||||||
|
* │ beep(ok) ← camera.parent.beep │
|
||||||
|
* └────────────────────────────────────────────────────────────┘
|
||||||
|
*/
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: var(--c-bg, #0d0620); color: var(--c-ink, #fff);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
--c-bg: #0d0620; --c-surface: #221440; --c-line: rgba(255,255,255,.18);
|
||||||
|
--c-ink: #fff; --c-gold: #fbbf24;
|
||||||
|
}
|
||||||
|
#chrome {
|
||||||
|
height: 48px; min-height: 48px; flex-shrink: 0;
|
||||||
|
background: #1a0e3a; border-bottom: 1px solid rgba(255,255,255,.15);
|
||||||
|
display: flex; align-items: center; padding: 0 16px; gap: 12px;
|
||||||
|
}
|
||||||
|
#chrome-title { font-size: 15px; font-weight: 700; }
|
||||||
|
#chrome .sp { flex: 1; }
|
||||||
|
#dots { display: flex; gap: 8px; }
|
||||||
|
#dots span {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,.2); transition: background .3s; display: inline-block;
|
||||||
|
}
|
||||||
|
#dots span.active { background: #a78bfa; }
|
||||||
|
#dots span.done { background: var(--c-gold); }
|
||||||
|
#room-wrap { flex: 1; position: relative; min-height: 0; }
|
||||||
|
#room-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||||
|
/* Overlay-uri */
|
||||||
|
.overlay {
|
||||||
|
display: none; position: absolute; inset: 0;
|
||||||
|
flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 18px; padding: 24px; text-align: center; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.overlay.show { display: flex; }
|
||||||
|
/* Intro */
|
||||||
|
#intro { background: var(--c-bg); }
|
||||||
|
#intro h1 { margin: 0; font-size: clamp(22px,5vw,36px); font-weight: 900; }
|
||||||
|
#intro .story-text { color: rgba(255,255,255,.8); max-width: 56ch; line-height: 1.6; }
|
||||||
|
#intro .promise { color: rgba(255,255,255,.5); font-size: 14px; }
|
||||||
|
/* Coridor */
|
||||||
|
#corridor { background: var(--c-bg); }
|
||||||
|
#corr-reward { display: flex; align-items: center; gap: 16px; }
|
||||||
|
#corr-stars { font-size: 26px; letter-spacing: 3px; color: var(--c-gold); }
|
||||||
|
#corr-letter { font-size: 56px; font-weight: 900; color: var(--c-gold); line-height: 1; }
|
||||||
|
#corr-label { color: rgba(255,255,255,.6); font-size: 13px; }
|
||||||
|
#corr-next { color: rgba(255,255,255,.75); font-size: 15px; font-weight: 600; }
|
||||||
|
#corr-door { display: flex; align-items: center; justify-content: center; flex: 1; min-height: 0; padding: 8px 0; }
|
||||||
|
/* Skip */
|
||||||
|
#skip-banner { background: var(--c-bg); }
|
||||||
|
/* ===== UȘILE — 5 stiluri × 3 stări ===== */
|
||||||
|
:root { --c-gold: #fbbf24; }
|
||||||
|
/* Common */
|
||||||
|
.door-lock {
|
||||||
|
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: rgba(0,0,0,.65); border-radius: 6px; padding: 2px 7px;
|
||||||
|
font-size: 13px; line-height: 1.4; z-index: 2; pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes door-open {
|
||||||
|
0% { transform: scale(1) rotateY(0deg); opacity: 1; }
|
||||||
|
50% { transform: scale(1.06) rotateY(-30deg); opacity: .8; }
|
||||||
|
100% { transform: scale(.85) rotateY(-90deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
.opening { animation: door-open .25s cubic-bezier(.4,0,1,1) forwards; transform-origin: left center; perspective: 600px; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .opening { animation: none; opacity: 0; } }
|
||||||
|
/* Classic */
|
||||||
|
.door-classic {
|
||||||
|
width: 88px; height: 124px; position: relative;
|
||||||
|
background: #fff; border-radius: 10px; border: 1.5px solid rgba(0,0,0,.06);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.9);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: transform .25s cubic-bezier(.22,1,.36,1), box-shadow .25s, filter .25s;
|
||||||
|
}
|
||||||
|
.door-classic .dq { font-size: 50px; font-weight: 900; line-height: 1; color: var(--c-gold); text-shadow: 0 2px 14px rgba(251,191,36,.6); user-select: none; }
|
||||||
|
.door-classic::after { content: ''; position: absolute; right: 12px; top: 50%; width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,.22); margin-top: -4px; }
|
||||||
|
.door-classic.stuck { filter: grayscale(1) brightness(.6); }
|
||||||
|
.door-classic.crescendo { transform: scale(1.35); box-shadow: 0 0 0 3px var(--c-gold), 0 14px 48px rgba(251,191,36,.35), 0 8px 32px rgba(0,0,0,.55); }
|
||||||
|
/* Terminal */
|
||||||
|
.door-terminal {
|
||||||
|
width: 88px; height: 124px; position: relative;
|
||||||
|
background: #000; border: 2px solid #39ff6e; overflow: hidden;
|
||||||
|
box-shadow: 0 0 16px rgba(57,255,110,.4), inset 0 0 12px rgba(57,255,110,.07);
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
|
||||||
|
transition: transform .25s cubic-bezier(.22,1,.36,1), box-shadow .25s, filter .25s;
|
||||||
|
}
|
||||||
|
.door-terminal::before { content: ''; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, rgba(0,0,0,.32) 0 1px, transparent 1px 3px); }
|
||||||
|
.door-terminal .dt-txt { font: 700 10px/1.2 "Courier New", monospace; letter-spacing: .12em; color: #39ff6e; text-shadow: 0 0 8px rgba(57,255,110,.75); z-index: 1; }
|
||||||
|
.door-terminal .dt-cur { font: 16px/1 "Courier New", monospace; color: #39ff6e; text-shadow: 0 0 8px rgba(57,255,110,.75); animation: dt-blink 1s step-end infinite; z-index: 1; }
|
||||||
|
@keyframes dt-blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
|
||||||
|
.door-terminal.stuck { filter: grayscale(1) brightness(.55); border-color: #444; box-shadow: none; }
|
||||||
|
.door-terminal.stuck .dt-cur { animation: none; opacity: 0; }
|
||||||
|
.door-terminal.crescendo { transform: scale(1.35); box-shadow: 0 0 0 2px #39ff6e, 0 0 32px rgba(57,255,110,.7), inset 0 0 18px rgba(57,255,110,.14); }
|
||||||
|
/* Arcade */
|
||||||
|
.door-arcade {
|
||||||
|
width: 88px; height: 124px; position: relative;
|
||||||
|
background: #18102e; border: 4px solid #4ade80;
|
||||||
|
box-shadow: inset 0 0 0 4px #166534, 0 0 0 4px #0a1606, 0 8px 24px rgba(0,0,0,.7);
|
||||||
|
display: flex; align-items: center; justify-content: center; image-rendering: pixelated;
|
||||||
|
transition: transform .25s cubic-bezier(.22,1,.36,1), box-shadow .25s, filter .25s;
|
||||||
|
}
|
||||||
|
.door-arcade::after { content: ''; position: absolute; right: 12px; top: 50%; margin-top: -5px; width: 10px; height: 10px; background: var(--c-gold); box-shadow: inset -2px -2px 0 #b45309, inset 1px 1px 0 #fde68a; }
|
||||||
|
.door-arcade .da-sprite { font: 900 24px/1 ui-monospace, monospace; color: #4ade80; text-shadow: 0 0 10px rgba(74,222,128,.55); user-select: none; }
|
||||||
|
.door-arcade.stuck { filter: grayscale(1) brightness(.55); box-shadow: none; }
|
||||||
|
.door-arcade.crescendo { transform: scale(1.35); box-shadow: inset 0 0 0 4px #166534, 0 0 0 4px #0a1606, 0 0 0 8px #4ade80, 0 0 28px rgba(74,222,128,.5), 0 8px 24px rgba(0,0,0,.7); }
|
||||||
|
/* Chat */
|
||||||
|
.door-chat {
|
||||||
|
width: 72px; height: 124px; position: relative;
|
||||||
|
background: linear-gradient(165deg, #1d4ed8 0%, #1e3a8a 100%);
|
||||||
|
border-radius: 12px; border: 1.5px solid rgba(255,255,255,.14);
|
||||||
|
box-shadow: 0 8px 28px rgba(29,78,216,.5), inset 0 1px 0 rgba(255,255,255,.2);
|
||||||
|
display: flex; flex-direction: column; align-items: center; padding: 8px 6px 10px; gap: 5px;
|
||||||
|
transition: transform .25s cubic-bezier(.22,1,.36,1), box-shadow .25s, filter .25s;
|
||||||
|
}
|
||||||
|
.door-chat .dc-notch { width: 18px; height: 4px; background: rgba(0,0,0,.4); border-radius: 2px; flex-shrink: 0; }
|
||||||
|
.door-chat .dc-screen { flex: 1; width: 100%; background: #0f172a; border-radius: 7px; padding: 7px 5px; display: flex; flex-direction: column; gap: 5px; overflow: hidden; }
|
||||||
|
.door-chat .dc-bub { border-radius: 8px; padding: 4px 7px; font-size: 9px; color: rgba(255,255,255,.85); line-height: 1.2; max-width: 80%; }
|
||||||
|
.door-chat .dc-npc { background: #1e40af; align-self: flex-start; }
|
||||||
|
.door-chat .dc-me { background: #3b82f6; align-self: flex-end; }
|
||||||
|
.door-chat .dc-home { width: 22px; height: 3px; background: rgba(255,255,255,.3); border-radius: 2px; flex-shrink: 0; }
|
||||||
|
.door-chat.stuck { filter: grayscale(1) brightness(.55); }
|
||||||
|
.door-chat.crescendo { transform: scale(1.35); box-shadow: 0 0 0 2px #3b82f6, 0 12px 48px rgba(29,78,216,.7), inset 0 1px 0 rgba(255,255,255,.25); }
|
||||||
|
/* Point */
|
||||||
|
.door-point { width: 88px; height: 124px; position: relative; display: flex; align-items: center; justify-content: center; transition: transform .25s cubic-bezier(.22,1,.36,1), filter .25s; }
|
||||||
|
.door-point svg { width: 88px; height: 124px; display: block; }
|
||||||
|
.door-point.stuck { filter: grayscale(1) brightness(.6); }
|
||||||
|
.door-point.crescendo { transform: scale(1.35); filter: drop-shadow(0 0 12px rgba(243,207,109,.6)) drop-shadow(0 0 32px rgba(138,90,43,.35)); }
|
||||||
|
#skip-banner h2 { margin: 0; font-size: 22px; }
|
||||||
|
#skip-code { font-family: ui-monospace, monospace; font-size: 12px; color: rgba(255,255,255,.35); margin-top: 4px; }
|
||||||
|
/* Final */
|
||||||
|
#finale { background: var(--c-bg); }
|
||||||
|
#finale h1 { margin: 0; font-size: 28px; }
|
||||||
|
#fin-stars { font-size: 26px; color: var(--c-gold); letter-spacing: 4px; }
|
||||||
|
#fin-word { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
#fin-word span {
|
||||||
|
width: 44px; height: 52px; background: var(--accent); border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 26px; font-weight: 800; animation: flipin .6s ease backwards;
|
||||||
|
}
|
||||||
|
@keyframes flipin { from { transform: rotateX(90deg); } to { transform: rotateX(0); } }
|
||||||
|
#fin-msg { color: rgba(255,255,255,.8); max-width: 56ch; }
|
||||||
|
/* Butoane */
|
||||||
|
.btn-main {
|
||||||
|
font: inherit; font-size: 16px; font-weight: 700;
|
||||||
|
background: var(--accent); color: #fff; border: none;
|
||||||
|
border-radius: 12px; padding: 14px 28px; cursor: pointer;
|
||||||
|
min-height: 44px; width: 100%; max-width: 320px;
|
||||||
|
}
|
||||||
|
.btn-main:hover { filter: brightness(1.1); }
|
||||||
|
.btn-main:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn-skip {
|
||||||
|
font: inherit; font-size: 15px; font-weight: 700;
|
||||||
|
background: #4b5563; color: #fff; border: none;
|
||||||
|
border-radius: 12px; padding: 12px 24px; cursor: pointer; min-height: 44px;
|
||||||
|
}
|
||||||
|
.confetti { position: fixed; top: -12px; width: 9px; height: 14px; z-index: 99; animation: fall linear forwards; }
|
||||||
|
@keyframes fall { to { transform: translateY(105vh) rotate(720deg); } }
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
#chrome { height: 40px; min-height: 40px; }
|
||||||
|
#chrome-title { font-size: 13px; }
|
||||||
|
#dots span { width: 8px; height: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="chrome">
|
||||||
|
<span id="chrome-title">${esc(cfg.title)}</span>
|
||||||
|
<div class="sp"></div>
|
||||||
|
<div id="dots"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="room-wrap">
|
||||||
|
<iframe id="room-frame" data-room title="Camera curentă"></iframe>
|
||||||
|
|
||||||
|
<div id="intro" class="overlay show">
|
||||||
|
<h1 id="intro-title"></h1>
|
||||||
|
<p class="story-text" id="intro-story"></p>
|
||||||
|
<p class="promise" id="intro-promise"></p>
|
||||||
|
<button class="btn-main" id="btn-start">Începe aventura</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="corridor" class="overlay">
|
||||||
|
<div id="corr-reward">
|
||||||
|
<div>
|
||||||
|
<div id="corr-label">Litera câștigată</div>
|
||||||
|
<div id="corr-letter"></div>
|
||||||
|
</div>
|
||||||
|
<div id="corr-stars"></div>
|
||||||
|
</div>
|
||||||
|
<div id="corr-door"></div>
|
||||||
|
<div id="corr-next"></div>
|
||||||
|
<button class="btn-main" id="btn-next">Deschide ușa →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="skip-banner" class="overlay">
|
||||||
|
<h2>⚠️ Ușa asta e înțepenită!</h2>
|
||||||
|
<div id="skip-door"></div>
|
||||||
|
<p>Camera nu a răspuns. Poți sări la cea următoare.</p>
|
||||||
|
<div class="skip-code" id="skip-code"></div>
|
||||||
|
<button class="btn-skip" id="btn-skip">Sari la camera următoare</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="finale" class="overlay" data-final>
|
||||||
|
<h1>🏆 Evadare reușită!</h1>
|
||||||
|
<div class="fstars" id="fin-stars"></div>
|
||||||
|
<p>Cuvântul magic:</p>
|
||||||
|
<div id="fin-word"></div>
|
||||||
|
<p id="fin-msg"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/*
|
||||||
|
* ASCII DIAGRAM — contractul parent.*:
|
||||||
|
* ┌──────────── orchestrator (window) ─────────────────────────┐
|
||||||
|
* │ nextRoom({idx,stars,letter}) ← camera.parent.nextRoom │
|
||||||
|
* │ roomReady(idx) ← camera.parent.roomReady │
|
||||||
|
* │ roomError(idx,msg) ← camera.parent.roomError │
|
||||||
|
* │ beep(ok) ← camera.parent.beep │
|
||||||
|
* └────────────────────────────────────────────────────────────┘
|
||||||
|
* Idempotență: nextRoom/roomError acceptate doar de la activeWindow.
|
||||||
|
* roomDone[idx]=true după primul nextRoom → duplicatele ignorate.
|
||||||
|
* Timeout 4s → skipRoom → aceeași compoziție de coridor (D5).
|
||||||
|
*/
|
||||||
|
var TPL = ${tplJson};
|
||||||
|
var MASTER = ${masterJson};
|
||||||
|
var ROTATION = ['classic','terminal','arcade','chat','point'];
|
||||||
|
var TOKEN = '__CFG__';
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--accent', MASTER.color || '#6d28d9');
|
||||||
|
|
||||||
|
var N = MASTER.puzzles.length;
|
||||||
|
var totalStars = 0;
|
||||||
|
var collected = [];
|
||||||
|
var skipped = {};
|
||||||
|
|
||||||
|
var activeIdx = -1;
|
||||||
|
var activeWindow = null;
|
||||||
|
var readyTimer = null;
|
||||||
|
var roomDone = {};
|
||||||
|
|
||||||
|
/* ----- Resume — safeStore (D3) + djb2 hash (D11) ----- */
|
||||||
|
function djb2(s){
|
||||||
|
var h = 5381;
|
||||||
|
for(var i=0;i<s.length;i++) h = ((h<<5)+h) ^ s.charCodeAt(i);
|
||||||
|
return (h >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
var _RESUME_KEY = 'esc-camp-' + djb2(JSON.stringify(MASTER));
|
||||||
|
function safeGet(){ try{ return JSON.parse(sessionStorage.getItem(_RESUME_KEY)); }catch(e){ return null; } }
|
||||||
|
function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v)); }catch(e){ /* webview restrictat — continuă fără resume */ } }
|
||||||
|
function saveProgress(){
|
||||||
|
safeSet({ idx: activeIdx, totalStars: totalStars, collected: collected.slice(), skipped: skipped });
|
||||||
|
}
|
||||||
|
function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); }catch(e){} }
|
||||||
|
|
||||||
|
var frameEl = document.getElementById('room-frame');
|
||||||
|
var introEl = document.getElementById('intro');
|
||||||
|
var corridorEl = document.getElementById('corridor');
|
||||||
|
var skipEl = document.getElementById('skip-banner');
|
||||||
|
var finaleEl = document.getElementById('finale');
|
||||||
|
|
||||||
|
/* ----- Dots ----- */
|
||||||
|
function buildDots(){
|
||||||
|
var d = document.getElementById('dots'); d.innerHTML = '';
|
||||||
|
for(var i=0;i<N;i++){
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.id = 'dot-'+i;
|
||||||
|
s.setAttribute('aria-label','Camera '+(i+1)+' din '+N);
|
||||||
|
d.appendChild(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setDot(i,cls){ var d=document.getElementById('dot-'+i); if(d) d.className=cls; }
|
||||||
|
|
||||||
|
/* ----- Ușa coridorului (§Design pct.7) ----- */
|
||||||
|
function doorHtml(style, isLast, isStuck){
|
||||||
|
var cls = (isLast ? ' crescendo' : '') + (isStuck ? ' stuck' : '');
|
||||||
|
var lock = isStuck ? '<span class="door-lock">🔒</span>' : '';
|
||||||
|
if(style === 'classic') return '<div class="door-classic'+cls+'"><span class="dq">?</span>'+lock+'</div>';
|
||||||
|
if(style === 'terminal') return '<div class="door-terminal'+cls+'"><span class="dt-txt">'+(isStuck?'BLOCKED':'ACCESS')+'</span>'+(isStuck?'':' <span class="dt-cur">_</span>')+lock+'</div>';
|
||||||
|
if(style === 'arcade') return '<div class="door-arcade'+cls+'"><span class="da-sprite">▶</span>'+lock+'</div>';
|
||||||
|
if(style === 'chat') return '<div class="door-chat'+cls+'"><div class="dc-notch"></div><div class="dc-screen"><div class="dc-bub dc-npc">Salut!</div><div class="dc-bub dc-me">?</div></div><div class="dc-home"></div>'+lock+'</div>';
|
||||||
|
if(style === 'point') return '<div class="door-point'+cls+'"><svg viewBox="0 0 88 124" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="2" width="84" height="120" rx="6" fill="#5a3518"/><rect x="6" y="6" width="76" height="112" rx="4" fill="#7c4f2c"/><rect x="10" y="10" width="30" height="44" rx="3" fill="rgba(0,0,0,.22)"/><rect x="48" y="10" width="30" height="44" rx="3" fill="rgba(0,0,0,.22)"/><rect x="10" y="62" width="68" height="52" rx="3" fill="rgba(0,0,0,.15)"/><circle cx="67" cy="70" r="8" fill="#c8952a"/><circle cx="67" cy="70" r="5" fill="#f3cf6d"/><circle cx="67" cy="70" r="2" fill="#a07022"/><circle cx="32" cy="87" r="13" fill="none" stroke="#f3cf6d" stroke-width="3"/><line x1="42" y1="97" x2="50" y2="105" stroke="#f3cf6d" stroke-width="3.5" stroke-linecap="round"/></svg>'+lock+'</div>';
|
||||||
|
return doorHtml('point', isLast, isStuck); /* fallback */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Beep — singurul AudioContext (D2) ----- */
|
||||||
|
function beep(ok){
|
||||||
|
try{
|
||||||
|
var ctx=beep._ctx||(beep._ctx=new(window.AudioContext||window.webkitAudioContext)());
|
||||||
|
var t=ctx.currentTime; var fs=ok?[523,784]:[196];
|
||||||
|
fs.forEach(function(f,k){
|
||||||
|
var o=ctx.createOscillator(),g=ctx.createGain();
|
||||||
|
o.frequency.value=f; o.type='triangle';
|
||||||
|
g.gain.setValueAtTime(0.12,t+k*0.09);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001,t+k*0.09+0.25);
|
||||||
|
o.connect(g); g.connect(ctx.destination);
|
||||||
|
o.start(t+k*0.09); o.stop(t+k*0.09+0.3);
|
||||||
|
});
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- parent.* API ----- */
|
||||||
|
|
||||||
|
window.nextRoom = function(data){
|
||||||
|
/* Guard: doar de la camera activă (D5) */
|
||||||
|
if(!activeWindow || frameEl.contentWindow !== activeWindow){
|
||||||
|
console.log('[campaign] nextRoom ignorat — frame stale'); return;
|
||||||
|
}
|
||||||
|
var idx = data ? +data.idx : activeIdx;
|
||||||
|
/* Idempotență (D4) */
|
||||||
|
if(roomDone[idx]){ console.log('[campaign] nextRoom ignorat — idx deja încheiat', idx); return; }
|
||||||
|
if(idx !== activeIdx){ console.log('[campaign] nextRoom idx mismatch ignorat'); return; }
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
roomDone[idx] = true;
|
||||||
|
totalStars += (data.stars || 0);
|
||||||
|
var letter = String(data.letter || '').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase();
|
||||||
|
if(letter) collected.push(letter);
|
||||||
|
setDot(idx,'done');
|
||||||
|
saveProgress();
|
||||||
|
console.log('[campaign] camera',idx,'done. stars=',data.stars,'letter=',letter);
|
||||||
|
var next = idx + 1;
|
||||||
|
if(next >= N){ clearProgress(); showFinale(); } else { showCorridor(idx, data, next); }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.roomReady = function(idx){
|
||||||
|
console.log('[campaign] roomReady',idx);
|
||||||
|
if(+idx !== activeIdx) return;
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
frameEl.setAttribute('data-room-ready','true'); /* hook pentru tests */
|
||||||
|
};
|
||||||
|
|
||||||
|
window.roomError = function(idx, msg){
|
||||||
|
console.warn('[campaign] roomError',idx,msg);
|
||||||
|
/* roomError are semantică ORICÂND (post-ready inclus, D5) */
|
||||||
|
if(!activeWindow || frameEl.contentWindow !== activeWindow) return;
|
||||||
|
if(+idx !== activeIdx) return;
|
||||||
|
if(roomDone[idx]) return;
|
||||||
|
skipRoom(+idx, String(msg||'eroare'));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----- Timeout 4s → skip (T3) ----- */
|
||||||
|
function startReadyTimer(idx){
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
readyTimer = setTimeout(function(){
|
||||||
|
if(roomDone[idx]) return;
|
||||||
|
console.warn('[campaign] timeout 4s — skip',idx);
|
||||||
|
skipRoom(idx,'timeout');
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipRoom(idx, reason){
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
roomDone[idx] = true;
|
||||||
|
skipped[idx] = true;
|
||||||
|
setDot(idx,'done');
|
||||||
|
saveProgress();
|
||||||
|
var style = (MASTER.puzzles[idx]&&(MASTER.puzzles[idx].style||ROTATION[idx%5]))||'?';
|
||||||
|
var code = style + '\\u00b7' + idx;
|
||||||
|
showSkipBanner(idx, code, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Montare cameră ----- */
|
||||||
|
function mountRoom(idx){
|
||||||
|
activeIdx = idx;
|
||||||
|
var puzzle = MASTER.puzzles[idx];
|
||||||
|
var style = (puzzle&&puzzle.style) || ROTATION[idx % ROTATION.length];
|
||||||
|
var tpl = TPL[style];
|
||||||
|
if(!tpl){
|
||||||
|
/* stil negăsit în template-uri — skip imediat */
|
||||||
|
console.warn('[campaign] template lipsă pentru stil',style);
|
||||||
|
skipRoom(idx,'template lipsă: '+style); return;
|
||||||
|
}
|
||||||
|
var camCfg = {
|
||||||
|
title: MASTER.title, player: MASTER.player, color: MASTER.color,
|
||||||
|
style: style, charName: MASTER.charName,
|
||||||
|
story: MASTER.story, finalMessage: MASTER.finalMessage,
|
||||||
|
puzzles: [puzzle],
|
||||||
|
_campaign: {
|
||||||
|
idx: idx, total: N,
|
||||||
|
stars: totalStars, letters: collected.slice(), deadline: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* json cu replace-funcție (D1 + D6) */
|
||||||
|
var json = JSON.stringify(camCfg).replace(/</g,'\\u003c');
|
||||||
|
var srcdoc = tpl.replace(TOKEN, function(){ return json; });
|
||||||
|
|
||||||
|
hideAll();
|
||||||
|
setDot(idx,'active');
|
||||||
|
activeWindow = null;
|
||||||
|
frameEl.removeAttribute('data-room-ready');
|
||||||
|
frameEl.srcdoc = srcdoc;
|
||||||
|
setTimeout(function(){ activeWindow = frameEl.contentWindow; }, 0);
|
||||||
|
startReadyTimer(idx);
|
||||||
|
|
||||||
|
var isLast = (idx === N - 1);
|
||||||
|
document.getElementById('chrome-title').textContent = isLast
|
||||||
|
? MASTER.title + ' — Ultima cameră!' : MASTER.title;
|
||||||
|
console.log('[campaign] montat camera',idx,'stil',style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Coridor ----- */
|
||||||
|
function showCorridor(doneIdx, data, nextIdx){
|
||||||
|
hideAll();
|
||||||
|
var s = data.stars || 0; var stars = '';
|
||||||
|
for(var i=0;i<s;i++) stars += '\\u2605';
|
||||||
|
document.getElementById('corr-stars').textContent = stars || '\\u2606';
|
||||||
|
var letter = String(data.letter||'').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase();
|
||||||
|
document.getElementById('corr-letter').textContent = letter || '\\u2014';
|
||||||
|
var styleNames = {classic:'Clasic',terminal:'Terminal Retro',arcade:'Arcade Pixel',chat:'Story Chat',point:'Point-and-Click'};
|
||||||
|
var nextStyle = (MASTER.puzzles[nextIdx] && (MASTER.puzzles[nextIdx].style || ROTATION[nextIdx%5])) || 'classic';
|
||||||
|
var isLast = (nextIdx === N - 1);
|
||||||
|
document.getElementById('corr-next').textContent =
|
||||||
|
isLast ? '\\u2605 Ultima cameră!' : 'Camera '+(nextIdx+1)+' — '+styleNames[nextStyle];
|
||||||
|
/* Ușa ca erou (§Design pct.2 + pct.6) */
|
||||||
|
var doorEl = document.getElementById('corr-door');
|
||||||
|
doorEl.innerHTML = doorHtml(nextStyle, isLast, false);
|
||||||
|
corridorEl.classList.add('show');
|
||||||
|
var btn = document.getElementById('btn-next');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.onclick = function(){
|
||||||
|
btn.disabled = true; /* idempotență buton (T4) */
|
||||||
|
/* Animație deschidere ușă ~250ms (§Design pct.4) */
|
||||||
|
var d = doorEl.firstElementChild;
|
||||||
|
if(d) d.classList.add('opening');
|
||||||
|
setTimeout(function(){ mountRoom(nextIdx); }, 280);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Skip banner ----- */
|
||||||
|
function showSkipBanner(idx, code, reason){
|
||||||
|
hideAll();
|
||||||
|
var stuckStyle = (MASTER.puzzles[idx] && (MASTER.puzzles[idx].style || ROTATION[idx%5])) || 'classic';
|
||||||
|
document.getElementById('skip-door').innerHTML = doorHtml(stuckStyle, false, true);
|
||||||
|
document.getElementById('skip-code').textContent = 'Cod: ' + code + ' (' + reason + ')';
|
||||||
|
skipEl.classList.add('show');
|
||||||
|
var next = idx + 1;
|
||||||
|
var btn = document.getElementById('btn-skip');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.onclick = function(){
|
||||||
|
btn.disabled = true;
|
||||||
|
if(next >= N){ showFinale(); } else { showCorridor(idx,{stars:0,letter:''},next); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Final ----- */
|
||||||
|
function showFinale(){
|
||||||
|
hideAll(); finaleEl.classList.add('show');
|
||||||
|
var wEl = document.getElementById('fin-word'); wEl.innerHTML = '';
|
||||||
|
collected.forEach(function(l,j){
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.textContent = l;
|
||||||
|
s.style.animationDelay = (j*0.18)+'s';
|
||||||
|
wEl.appendChild(s);
|
||||||
|
});
|
||||||
|
/* dăle-lacăt pentru camere sărite */
|
||||||
|
Object.keys(skipped).forEach(function(i){
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.textContent = '\\uD83D\\uDD12'; /* 🔒 */
|
||||||
|
s.title = 'Camera '+(+i+1)+' sărită';
|
||||||
|
s.style.fontSize = '22px';
|
||||||
|
wEl.appendChild(s);
|
||||||
|
});
|
||||||
|
document.getElementById('fin-stars').textContent = totalStars + ' / ' + (N*3) + ' \\u2605';
|
||||||
|
var msg = MASTER.finalMessage || '';
|
||||||
|
var pl = MASTER.player || '';
|
||||||
|
document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg;
|
||||||
|
beep(true); confetti();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Confetti ----- */
|
||||||
|
function confetti(){
|
||||||
|
var colors=[MASTER.color||'#6d28d9','#fbbf24','#34d399','#60a5fa','#f472b6'];
|
||||||
|
for(var i=0;i<90;i++){
|
||||||
|
var c=document.createElement('div'); c.className='confetti';
|
||||||
|
c.style.left=(i*137%100)+'vw'; c.style.background=colors[i%colors.length];
|
||||||
|
c.style.animationDuration=(2.2+(i*53%18)/10)+'s';
|
||||||
|
c.style.animationDelay=((i*31%14)/10)+'s';
|
||||||
|
document.body.appendChild(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAll(){
|
||||||
|
[introEl,corridorEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Intro ----- */
|
||||||
|
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(){ clearProgress(); mountRoom(0); };
|
||||||
|
|
||||||
|
buildDots();
|
||||||
|
|
||||||
|
/* ----- Resume la reload (D11) ----- */
|
||||||
|
(function tryResume(){
|
||||||
|
if(MASTER._noResume) return; /* preview-ul nu reia niciodată */
|
||||||
|
var saved = safeGet();
|
||||||
|
if(!saved || typeof saved.idx !== 'number' || saved.idx < 0) return;
|
||||||
|
/* restaurăm starea */
|
||||||
|
totalStars = saved.totalStars || 0;
|
||||||
|
collected = saved.collected || [];
|
||||||
|
skipped = saved.skipped || {};
|
||||||
|
Object.keys(skipped).forEach(function(k){ roomDone[+k] = true; setDot(+k,'done'); });
|
||||||
|
/* repornim de la coridorul camerei next */
|
||||||
|
var resumeIdx = saved.idx + 1;
|
||||||
|
if(resumeIdx >= N){
|
||||||
|
/* ultima cameră deja terminată — mergi direct la final */
|
||||||
|
showFinale(); return;
|
||||||
|
}
|
||||||
|
showCorridor(saved.idx, {stars:0, letter: (collected[collected.length-1]||'')}, resumeIdx);
|
||||||
|
})();
|
||||||
<\/script>
|
<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<button id="fAgain">Joaca din nou</button>
|
<button id="fAgain">Joaca din nou</button>
|
||||||
</div></div>
|
</div></div>
|
||||||
<script>
|
<script>
|
||||||
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","style":"arcade","charName":"Alex","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":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D"},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A"},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R"}]};
|
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"arcade"};
|
||||||
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
||||||
var totalStars = 0;
|
var totalStars = 0;
|
||||||
function el(id){ return document.getElementById(id); }
|
function el(id){ return document.getElementById(id); }
|
||||||
@@ -80,8 +80,16 @@ function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){
|
|||||||
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
||||||
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
||||||
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
||||||
function beep(ok){ try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
||||||
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
||||||
|
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){
|
||||||
|
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
|
||||||
|
var _cs = document.createElement('style');
|
||||||
|
_cs.textContent = 'h1{display:none!important}.progress{display:none!important}.meta{display:none!important}';
|
||||||
|
(document.head || document.documentElement).appendChild(_cs);
|
||||||
|
}
|
||||||
var N = CFG.puzzles.length;
|
var N = CFG.puzzles.length;
|
||||||
var GW = 13, RH = 4, ROOMS = N + 1, GH = ROOMS * RH + 1;
|
var GW = 13, RH = 4, ROOMS = N + 1, GH = ROOMS * RH + 1;
|
||||||
var TS = 38, VR = Math.min(GH, 11);
|
var TS = 38, VR = Math.min(GH, 11);
|
||||||
@@ -223,6 +231,11 @@ function mCheck(given){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showFinal(){
|
function showFinal(){
|
||||||
|
if(CFG._campaign){
|
||||||
|
var L = finalWord().charAt(0);
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L}); }catch(e){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \u2605';
|
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \u2605';
|
||||||
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
||||||
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
||||||
@@ -233,6 +246,7 @@ function showFinal(){
|
|||||||
}
|
}
|
||||||
el('fAgain').onclick = function(){ location.reload(); };
|
el('fAgain').onclick = function(){ location.reload(); };
|
||||||
updateHud(); draw();
|
updateHud(); draw();
|
||||||
|
roomReady();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
527
exemplu-campanie.html
Normal file
527
exemplu-campanie.html
Normal file
File diff suppressed because one or more lines are too long
@@ -59,7 +59,7 @@
|
|||||||
<button id="fAgain">Joaca din nou</button>
|
<button id="fAgain">Joaca din nou</button>
|
||||||
</div></div>
|
</div></div>
|
||||||
<script>
|
<script>
|
||||||
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","style":"chat","charName":"Alex","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":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D"},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A"},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R"}]};
|
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"chat"};
|
||||||
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
||||||
var totalStars = 0;
|
var totalStars = 0;
|
||||||
function el(id){ return document.getElementById(id); }
|
function el(id){ return document.getElementById(id); }
|
||||||
@@ -69,8 +69,16 @@ function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){
|
|||||||
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
||||||
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
||||||
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
||||||
function beep(ok){ try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
||||||
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
||||||
|
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){
|
||||||
|
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
|
||||||
|
var _cs = document.createElement('style');
|
||||||
|
_cs.textContent = 'h1{display:none!important}.progress{display:none!important}.meta{display:none!important}';
|
||||||
|
(document.head || document.documentElement).appendChild(_cs);
|
||||||
|
}
|
||||||
var who = (CFG.charName || 'Alex').trim() || 'Alex';
|
var who = (CFG.charName || 'Alex').trim() || 'Alex';
|
||||||
el('cn').textContent = who; el('av').textContent = who.charAt(0).toUpperCase();
|
el('cn').textContent = who; el('av').textContent = who.charAt(0).toUpperCase();
|
||||||
var msgs = el('msgs'), composer = el('composer');
|
var msgs = el('msgs'), composer = el('composer');
|
||||||
@@ -155,8 +163,16 @@ function next(){
|
|||||||
seq([(p.title ? p.title + '. ' : '') + p.question], function(){ setComposer(p); });
|
seq([(p.title ? p.title + '. ' : '') + p.question], function(){ setComposer(p); });
|
||||||
}
|
}
|
||||||
|
|
||||||
seq(['Salut' + (CFG.player ? ', ' + CFG.player : '') + '!'].concat(storyChunks()).concat(['Ma ajuti sa ies de aici?']), next);
|
var chatIntro = CFG._campaign
|
||||||
|
? ['Camera ' + (CFG._campaign.idx + 1) + ' din ' + CFG._campaign.total + '. Sa incepem!']
|
||||||
|
: ['Salut' + (CFG.player ? ', ' + CFG.player : '') + '!'].concat(storyChunks()).concat(['Ma ajuti sa ies de aici?']);
|
||||||
|
seq(chatIntro, next);
|
||||||
function showFinal(){
|
function showFinal(){
|
||||||
|
if(CFG._campaign){
|
||||||
|
var L = finalWord().charAt(0);
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L}); }catch(e){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \u2605';
|
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \u2605';
|
||||||
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
||||||
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
||||||
@@ -166,6 +182,7 @@ function showFinal(){
|
|||||||
beep(true); confetti();
|
beep(true); confetti();
|
||||||
}
|
}
|
||||||
el('fAgain').onclick = function(){ location.reload(); };
|
el('fAgain').onclick = function(){ location.reload(); };
|
||||||
|
roomReady();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","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":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D"},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A"},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R"}]};
|
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"classic"};
|
||||||
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
||||||
document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)');
|
document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)');
|
||||||
|
|
||||||
@@ -216,6 +216,11 @@ function check(given, expected) {
|
|||||||
function next() {
|
function next() {
|
||||||
idx++;
|
idx++;
|
||||||
if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
|
if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
|
||||||
|
if(CFG._campaign){
|
||||||
|
var L = ''; for(var ci=0;ci<CFG.puzzles.length;ci++){var lc=(CFG.puzzles[ci].letter||'').trim();if(lc)L+=lc.toUpperCase();}
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L.charAt(0)}); }catch(e){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
show('sFinal');
|
show('sFinal');
|
||||||
var max = CFG.puzzles.length * 3;
|
var max = CFG.puzzles.length * 3;
|
||||||
el('finalStars').textContent = totalStars + ' / ' + max + ' \u2605';
|
el('finalStars').textContent = totalStars + ' / ' + max + ' \u2605';
|
||||||
@@ -251,6 +256,7 @@ function confetti() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beep(ok) {
|
function beep(ok) {
|
||||||
|
if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; }
|
||||||
try {
|
try {
|
||||||
var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)());
|
var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)());
|
||||||
var t = ctx.currentTime;
|
var t = ctx.currentTime;
|
||||||
@@ -265,6 +271,8 @@ function beep(ok) {
|
|||||||
});
|
});
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} }
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<button id="fAgain">Joaca din nou</button>
|
<button id="fAgain">Joaca din nou</button>
|
||||||
</div></div>
|
</div></div>
|
||||||
<script>
|
<script>
|
||||||
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","style":"point","charName":"Alex","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":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D"},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A"},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R"}]};
|
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"point"};
|
||||||
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
||||||
var totalStars = 0;
|
var totalStars = 0;
|
||||||
function el(id){ return document.getElementById(id); }
|
function el(id){ return document.getElementById(id); }
|
||||||
@@ -83,8 +83,16 @@ function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){
|
|||||||
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
||||||
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
||||||
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
||||||
function beep(ok){ try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
||||||
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
||||||
|
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){
|
||||||
|
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
|
||||||
|
var _cs = document.createElement('style');
|
||||||
|
_cs.textContent = 'h1{display:none!important}.progress{display:none!important}.meta{display:none!important}';
|
||||||
|
(document.head || document.documentElement).appendChild(_cs);
|
||||||
|
}
|
||||||
var N = CFG.puzzles.length, solvedFlags = [], solvedCount = 0;
|
var N = CFG.puzzles.length, solvedFlags = [], solvedCount = 0;
|
||||||
|
|
||||||
var POOL = [
|
var POOL = [
|
||||||
@@ -192,6 +200,11 @@ function mCheck(given){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showFinal(){
|
function showFinal(){
|
||||||
|
if(CFG._campaign){
|
||||||
|
var L = finalWord().charAt(0);
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L}); }catch(e){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \u2605';
|
el('fStars').textContent = totalStars + ' / ' + (CFG.puzzles.length * 3) + ' \u2605';
|
||||||
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
var w = finalWord(); var bw = el('fWord'); bw.innerHTML = '';
|
||||||
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
for (var j = 0; j < w.length; j++){ var s = document.createElement('span'); s.textContent = w.charAt(j); s.style.animationDelay = (j * 0.18) + 's'; bw.appendChild(s); }
|
||||||
@@ -202,6 +215,7 @@ function showFinal(){
|
|||||||
}
|
}
|
||||||
el('fAgain').onclick = function(){ location.reload(); };
|
el('fAgain').onclick = function(){ location.reload(); };
|
||||||
updateHud();
|
updateHud();
|
||||||
|
roomReady();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<div id="inline"><span>></span><input id="cmd" autocomplete="off" autofocus spellcheck="false"></div>
|
<div id="inline"><span>></span><input id="cmd" autocomplete="off" autofocus spellcheck="false"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","style":"terminal","charName":"Alex","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":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D"},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A"},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R"}]};
|
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"terminal"};
|
||||||
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
||||||
var totalStars = 0;
|
var totalStars = 0;
|
||||||
function el(id){ return document.getElementById(id); }
|
function el(id){ return document.getElementById(id); }
|
||||||
@@ -35,8 +35,16 @@ function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){
|
|||||||
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
||||||
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
||||||
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
||||||
function beep(ok){ try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
||||||
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
||||||
|
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){
|
||||||
|
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
|
||||||
|
var _cs = document.createElement('style');
|
||||||
|
_cs.textContent = 'h1{display:none!important}.progress{display:none!important}.meta{display:none!important}';
|
||||||
|
(document.head || document.documentElement).appendChild(_cs);
|
||||||
|
}
|
||||||
var idx = -1, attempts = 0, hintUsed = false, done = false;
|
var idx = -1, attempts = 0, hintUsed = false, done = false;
|
||||||
var solved = [];
|
var solved = [];
|
||||||
var out = el('out'), cmd = el('cmd');
|
var out = el('out'), cmd = el('cmd');
|
||||||
@@ -68,7 +76,10 @@ function echo(text, cls){ var d = document.createElement('div'); d.className = '
|
|||||||
function collected(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += solved[i] ? L.toUpperCase() + ' ' : '_ '; } return w.trim() || '(niciuna)'; }
|
function collected(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += solved[i] ? L.toUpperCase() + ' ' : '_ '; } return w.trim() || '(niciuna)'; }
|
||||||
|
|
||||||
var bar = '==============================================';
|
var bar = '==============================================';
|
||||||
say([bar, ' ' + CFG.title.toUpperCase(), bar, ' ', (CFG.player ? CFG.player + ', ' : '') + CFG.story, ' ', 'Comenzi: INDICIU, LITERE, AJUTOR. In rest, scrie raspunsul si apasa Enter.'], '', nextPuzzle);
|
var introLines = CFG._campaign
|
||||||
|
? [bar, ' ' + CFG.title.toUpperCase(), bar, 'Comenzi: INDICIU, AJUTOR. Scrie raspunsul si apasa Enter.']
|
||||||
|
: [bar, ' ' + CFG.title.toUpperCase(), bar, ' ', (CFG.player ? CFG.player + ', ' : '') + CFG.story, ' ', 'Comenzi: INDICIU, LITERE, AJUTOR. In rest, scrie raspunsul si apasa Enter.'];
|
||||||
|
say(introLines, '', nextPuzzle);
|
||||||
|
|
||||||
function nextPuzzle(){
|
function nextPuzzle(){
|
||||||
idx++; attempts = 0; hintUsed = false;
|
idx++; attempts = 0; hintUsed = false;
|
||||||
@@ -82,6 +93,13 @@ function nextPuzzle(){
|
|||||||
|
|
||||||
function finale(){
|
function finale(){
|
||||||
done = true;
|
done = true;
|
||||||
|
if(CFG._campaign){
|
||||||
|
var s = totalStars; var L = finalWord().charAt(0);
|
||||||
|
say(['>> CAMERA REZOLVATA! Stele: ' + s + (L ? ' | Litera: ' + L : '')], 'ok', function(){
|
||||||
|
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:s, letter:L}); }catch(e){}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
var w = finalWord().split('').join(' ');
|
var w = finalWord().split('').join(' ');
|
||||||
var lines = [' ', bar, ' E V A D A R E R E U S I T A', bar, 'Stele: ' + totalStars + ' / ' + (CFG.puzzles.length * 3)];
|
var lines = [' ', bar, ' E V A D A R E R E U S I T A', bar, 'Stele: ' + totalStars + ' / ' + (CFG.puzzles.length * 3)];
|
||||||
if (w) lines.push('Cuvantul magic: ' + w);
|
if (w) lines.push('Cuvantul magic: ' + w);
|
||||||
@@ -124,6 +142,7 @@ cmd.addEventListener('keydown', function(e){
|
|||||||
say(['>> ACCES RESPINS. Mai incearca.'], 'bad');
|
say(['>> ACCES RESPINS. Mai incearca.'], 'bad');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
roomReady();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<h1>Escape Room Builder</h1>
|
<h1>Escape Room Builder</h1>
|
||||||
<p>Builder-ul + cate un joc demo exportat in fiecare stil.</p>
|
<p>Builder-ul + cate un joc demo exportat in fiecare stil.</p>
|
||||||
<a class="builder" href="escape-builder.html">Builder <span>editor + preview live; schimba "Stil joc" si vezi transformarea pe loc</span></a>
|
<a class="builder" href="escape-builder.html">Builder <span>editor + preview live; schimba "Stil joc" si vezi transformarea pe loc</span></a>
|
||||||
|
<a href="exemplu-campanie.html" style="border-color:#a78bfa;background:rgba(167,139,250,.12)">🗺️ Campanie multi-stil <span>3 puzzle-uri × 3 stiluri diferite — ușa ca erou, coridor cu litera, cuvântul magic</span></a>
|
||||||
<a href="exemplu-clasic.html">Clasic (quiz) <span>carduri secventiale cu progres si litere</span></a>
|
<a href="exemplu-clasic.html">Clasic (quiz) <span>carduri secventiale cu progres si litere</span></a>
|
||||||
<a href="exemplu-terminal.html">Terminal retro <span>text adventure CRT; scrie raspunsul, INDICIU, LITERE</span></a>
|
<a href="exemplu-terminal.html">Terminal retro <span>text adventure CRT; scrie raspunsul, INDICIU, LITERE</span></a>
|
||||||
<a href="exemplu-arcade.html">Arcade pixel <span>sageti / WASD; usi incuiate, cufar final</span></a>
|
<a href="exemplu-arcade.html">Arcade pixel <span>sageti / WASD; usi incuiate, cufar final</span></a>
|
||||||
|
|||||||
700
tests/smoke.mjs
Normal file
700
tests/smoke.mjs
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
// @ts-check
|
||||||
|
/**
|
||||||
|
* tests/smoke.mjs — Escape Room Builder smoke & regression tests
|
||||||
|
*
|
||||||
|
* Setup (o singura data):
|
||||||
|
* npm install
|
||||||
|
* npx playwright install chromium
|
||||||
|
*
|
||||||
|
* Rulare:
|
||||||
|
* npx playwright test tests/smoke.mjs # suita completa
|
||||||
|
* npx playwright test tests/smoke.mjs --grep @regresie # regresie (baseline, acum)
|
||||||
|
* npx playwright test tests/smoke.mjs --grep @campanie # campanie (dupa integrator)
|
||||||
|
* npm test # alias pentru suita completa
|
||||||
|
*
|
||||||
|
* @see CLAUDE.md § Testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { writeFileSync, unlinkSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(__dirname, '..');
|
||||||
|
|
||||||
|
/** Converteste cale relativa la file:// URL. */
|
||||||
|
const fileURL = (name) => 'file://' + join(ROOT, name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ataseaza listeneri de erori si returneaza array-ul de erori.
|
||||||
|
* Test-ul trebuie sa asserteze `errors.length === 0` la final.
|
||||||
|
*/
|
||||||
|
function trackErrors(page) {
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`);
|
||||||
|
});
|
||||||
|
page.on('pageerror', err => errors.push(`[pageerror] ${err.message}`));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asteapta si rezolva un puzzle in modalul comun (motoarele arcade / point).
|
||||||
|
* Presupune ca modalul e deja vizibil.
|
||||||
|
*/
|
||||||
|
async function solveModal(page, puzzle) {
|
||||||
|
await expect(page.locator('#mOverlay')).toBeVisible({ timeout: 5000 });
|
||||||
|
if (puzzle.type === 'free') {
|
||||||
|
await page.locator('#mAnswers input[type=text]').fill(puzzle.answer);
|
||||||
|
await page.locator('#mAnswers button:not(.mhint):not(.mclose)').first().click();
|
||||||
|
} else if (puzzle.type === 'tf') {
|
||||||
|
await page.locator(`#mAnswers button:text("${puzzle.tfAnswer}")`).click();
|
||||||
|
} else {
|
||||||
|
// choice: gaseste varianta corecta (prefixata cu *)
|
||||||
|
const correct = puzzle.choices.split('\n')
|
||||||
|
.find(l => l.trim().startsWith('*'))?.replace(/^\*/, '').trim() ?? '';
|
||||||
|
await page.locator(`#mAnswers button:text("${correct}")`).click();
|
||||||
|
}
|
||||||
|
// Modalul se inchide dupa ~750ms animatie de success
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('mOverlay')?.style.display !== 'flex',
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SECTIUNEA 1 — REGRESIE: fiecare exemplu-*.html rezolvat pana la final
|
||||||
|
//
|
||||||
|
// Contractul: diff-ul campaniei modifica finalul tuturor celor 5 stiluri
|
||||||
|
// (ramura _campaign in SNIP.finalJs + finale() terminal + final classic).
|
||||||
|
// Aceste teste verifica ca finalul existent NU este stricat.
|
||||||
|
//
|
||||||
|
// Ruleaza ACUM ca baseline contra exemplu-*.html curente.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe('Regresie exemplu-*.html @regresie', () => {
|
||||||
|
|
||||||
|
test('exemplu-clasic.html — rezolvat pana la ecranul final', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('exemplu-clasic.html'));
|
||||||
|
|
||||||
|
// Start game
|
||||||
|
await page.locator('#btnStart').click();
|
||||||
|
await page.waitForSelector('#sGame.on', { timeout: 3000 });
|
||||||
|
|
||||||
|
// Puzzle 1: raspuns liber "56"
|
||||||
|
await page.locator('#answers input[type=text]').fill('56');
|
||||||
|
await page.locator('#answers button:text("Verifica")').click();
|
||||||
|
await page.waitForTimeout(1100); // asteapta animatia si next()
|
||||||
|
|
||||||
|
// Puzzle 2: adevarat/fals "Adevarat"
|
||||||
|
await page.locator('#answers button:text("Adevarat")').click();
|
||||||
|
await page.waitForTimeout(1100);
|
||||||
|
|
||||||
|
// Puzzle 3: variante "Paris"
|
||||||
|
await page.locator('#answers button:text("Paris")').click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
// Ecranul final trebuie sa fie vizibil
|
||||||
|
await expect(page.locator('#sFinal')).toHaveClass(/on/, { timeout: 3000 });
|
||||||
|
// Cuvantul magic "DAR" afişat ca litere individuale
|
||||||
|
const bigword = page.locator('#bigword');
|
||||||
|
await expect(bigword).toContainText('D');
|
||||||
|
await expect(bigword).toContainText('A');
|
||||||
|
await expect(bigword).toContainText('R');
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exemplu-terminal.html — rezolvat pana la ecranul final', async ({ page }) => {
|
||||||
|
// Animatia de typing poate fi lenta; marim timeout-ul pentru acest test
|
||||||
|
test.setTimeout(120000);
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('exemplu-terminal.html'));
|
||||||
|
|
||||||
|
// Asteapta ca intro-ul sa termine animatia de typing si primul puzzle sa apara.
|
||||||
|
// Nota: { timeout } in waitForFunction merge ca al doilea argument FARA arg intermediar.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('out')?.textContent?.includes('[1/'),
|
||||||
|
null, { timeout: 20000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Puzzle 1: raspuns liber "56"
|
||||||
|
// Folosim .press() pe locator (nu keyboard global) pentru focus garantat.
|
||||||
|
await page.locator('#cmd').fill('56');
|
||||||
|
await page.locator('#cmd').press('Enter');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('out')?.textContent?.includes('ACCES PERMIS'),
|
||||||
|
null, { timeout: 15000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Asteapta puzzle 2
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('out')?.textContent?.includes('[2/'),
|
||||||
|
null, { timeout: 15000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Puzzle 2: adevarat/fals "Adevarat"
|
||||||
|
await page.locator('#cmd').fill('Adevarat');
|
||||||
|
await page.locator('#cmd').press('Enter');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.getElementById('out')?.textContent?.match(/ACCES PERMIS/g) ?? []).length >= 2,
|
||||||
|
null, { timeout: 15000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Asteapta puzzle 3
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('out')?.textContent?.includes('[3/'),
|
||||||
|
null, { timeout: 15000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Puzzle 3: variante - "1" = Paris (prima optiune numerotata)
|
||||||
|
await page.locator('#cmd').fill('1');
|
||||||
|
await page.locator('#cmd').press('Enter');
|
||||||
|
|
||||||
|
// Asteapta textul de finale.
|
||||||
|
// IMPORTANT: textul e "E V A D A R E R E U S I T A" (litere cu spatii intre ele)!
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('out')?.textContent?.includes('E V A D A R E'),
|
||||||
|
null, { timeout: 20000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cuvantul magic "DAR" apare ca "D A R" (spaced) — verifica fiecare litera
|
||||||
|
const outText = await page.locator('#out').innerText();
|
||||||
|
expect(outText).toMatch(/D\s+A\s+R/);
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exemplu-arcade.html — rezolvat pana la ecranul final', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('exemplu-arcade.html'));
|
||||||
|
|
||||||
|
// Asteapta initializarea canvas + API-urilor de puzzle
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof openPuzzle !== 'undefined' && typeof onDoorSolved !== 'undefined',
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const puzzles = await page.evaluate(() => CFG.puzzles);
|
||||||
|
|
||||||
|
// Rezolva fiecare puzzle prin API-ul modal (simuleaza interactiunea cu usile)
|
||||||
|
for (let i = 0; i < puzzles.length; i++) {
|
||||||
|
await page.evaluate((idx) => openPuzzle(idx, onDoorSolved), i);
|
||||||
|
await solveModal(page, puzzles[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toate puzzle-urile rezolvate → trigger final (simuleaza ajungerea la cufar)
|
||||||
|
await page.evaluate(() => showFinal());
|
||||||
|
await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 3000 });
|
||||||
|
await expect(page.locator('#fWord')).toContainText('D');
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exemplu-chat.html — rezolvat pana la ecranul final', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('exemplu-chat.html'));
|
||||||
|
|
||||||
|
// Asteapta ca intro-ul (Salut + poveste + "Ma ajuti?") sa termine
|
||||||
|
// si primul puzzle (free) sa apara cu input in composer
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('composer')?.querySelector('input') !== null,
|
||||||
|
{ timeout: 20000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Puzzle 1: raspuns liber "56"
|
||||||
|
await page.locator('#composer input').fill('56');
|
||||||
|
await page.locator('#composer button:not(.chip)').first().click(); // "Trimite"
|
||||||
|
// Asteapta confirmarea "Asta era!"
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('msgs')?.textContent?.includes('Asta era'),
|
||||||
|
{ timeout: 12000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Puzzle 2: tf — asteapta chip-ul "Adevarat"
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const c = document.getElementById('composer');
|
||||||
|
return c && [...c.querySelectorAll('button.chip')]
|
||||||
|
.some(b => b.textContent.trim() === 'Adevarat');
|
||||||
|
},
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
await page.locator('#composer button.chip:text("Adevarat")').click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.getElementById('msgs')?.textContent?.match(/Asta era/g) ?? []).length >= 2,
|
||||||
|
{ timeout: 12000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Puzzle 3: choice — asteapta chip-ul "Paris"
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const c = document.getElementById('composer');
|
||||||
|
return c && [...c.querySelectorAll('button.chip')]
|
||||||
|
.some(b => b.textContent.trim() === 'Paris');
|
||||||
|
},
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
await page.locator('#composer button.chip:text("Paris")').click();
|
||||||
|
|
||||||
|
// Asteapta overlay-ul final
|
||||||
|
await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.locator('#fWord')).toContainText('D');
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exemplu-point.html — rezolvat pana la ecranul final', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('exemplu-point.html'));
|
||||||
|
|
||||||
|
// Asteapta randarea scenei SVG cu obiectele "hot"
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof openPuzzle !== 'undefined' && document.querySelectorAll('g.hot').length > 0,
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const puzzles = await page.evaluate(() => CFG.puzzles);
|
||||||
|
|
||||||
|
// Click fiecare obiect hot si rezolva puzzle-ul din modal
|
||||||
|
for (let i = 0; i < puzzles.length; i++) {
|
||||||
|
await page.locator(`g.hot[data-i="${i}"]`).click();
|
||||||
|
await solveModal(page, puzzles[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toate rezolvate → usa se deschide → click pe usa → ecranul final
|
||||||
|
await expect(page.locator('#door')).toHaveClass(/open/, { timeout: 3000 });
|
||||||
|
await page.locator('#door').click();
|
||||||
|
await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 3000 });
|
||||||
|
await expect(page.locator('#fWord')).toContainText('D');
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SECTIUNEA 2 — EDGE CASES (rulabile acum, fara campanie)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe('Edge cases @regresie', () => {
|
||||||
|
|
||||||
|
test('import JSON corupt — fara crash, builder ramane functional', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
|
||||||
|
// Dismiss alert-ul de eroare
|
||||||
|
page.on('dialog', d => d.accept());
|
||||||
|
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
// Scrie un fisier JSON corupt temporar si incarca-l
|
||||||
|
const tmpPath = join(ROOT, 'tests', '.tmp-corrupt-test.json');
|
||||||
|
writeFileSync(tmpPath, '{"title":"test","puzzles":[{INVALID_JSON_HERE}]}');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.locator('#fileLoad').setInputFiles(tmpPath);
|
||||||
|
await page.waitForTimeout(600); // asteapta alert + dismiss
|
||||||
|
} finally {
|
||||||
|
unlinkSync(tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builder-ul trebuie sa fie in continuare functional
|
||||||
|
await expect(page.locator('#gTitle')).toBeVisible();
|
||||||
|
await expect(page.locator('#addPuzzle')).toBeVisible();
|
||||||
|
// Starea existenta trebuie pastrata (nu resetata la corupt)
|
||||||
|
const title = await page.locator('#gTitle').inputValue();
|
||||||
|
expect(title.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert 320x568 fara overflow orizontal per stil (§Design pct. 11)
|
||||||
|
for (const stil of ['clasic', 'terminal', 'arcade', 'chat', 'point']) {
|
||||||
|
test(`320x568 fara overflow orizontal — ${stil}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 320, height: 568 });
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
|
||||||
|
await page.goto(fileURL(`exemplu-${stil}.html`));
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
const overflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth >
|
||||||
|
document.documentElement.clientWidth + 1
|
||||||
|
);
|
||||||
|
expect(overflow, `Overflow orizontal la 320x568 in exemplu-${stil}`).toBe(false);
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('regenerare demo-uri via gameHTML — fara erori de consola', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
// Verifica ca gameHTML genereaza HTML valid si fara erori pentru fiecare stil
|
||||||
|
const styles = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
const singlePuzzle = {
|
||||||
|
title: 'Q1', type: 'free', question: 'Test?', answer: 'da',
|
||||||
|
tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'T'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const style of styles) {
|
||||||
|
const html = await page.evaluate((args) => {
|
||||||
|
return gameHTML({
|
||||||
|
title: 'Test ' + args.style, player: 'Tester', color: '#6d28d9',
|
||||||
|
style: args.style, charName: 'Alex',
|
||||||
|
story: 'Poveste test.', finalMessage: 'Bravo!',
|
||||||
|
puzzles: [args.puzzle]
|
||||||
|
});
|
||||||
|
}, { style, puzzle: singlePuzzle });
|
||||||
|
|
||||||
|
// Verificari de baza pe stringul HTML generat
|
||||||
|
expect(typeof html, `${style}: gameHTML nu a returnat string`).toBe('string');
|
||||||
|
expect(html, `${style}: lipseste doctype`).toContain('<!doctype html');
|
||||||
|
expect(html, `${style}: contine "undefined"`).not.toContain('>undefined<');
|
||||||
|
|
||||||
|
// Incarca in pagina noua si verifica absenta erorilor
|
||||||
|
const genPage = await page.context().newPage();
|
||||||
|
const genErrors = [];
|
||||||
|
genPage.on('console', m => { if (m.type() === 'error') genErrors.push(m.text()); });
|
||||||
|
genPage.on('pageerror', e => genErrors.push(e.message));
|
||||||
|
|
||||||
|
await genPage.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||||
|
await genPage.waitForTimeout(500);
|
||||||
|
|
||||||
|
expect(genErrors, `${style} gameHTML erori:\n${genErrors.join('\n')}`).toHaveLength(0);
|
||||||
|
await genPage.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errors, 'Builder erori:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builder: JSON cu tip puzzle necunoscut → normalizat, fara crash', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
page.on('dialog', d => d.accept());
|
||||||
|
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
// Incarca JSON cu tip invalid si choices non-string
|
||||||
|
const tmpPath = join(ROOT, 'tests', '.tmp-invalid-type.json');
|
||||||
|
writeFileSync(tmpPath, JSON.stringify({
|
||||||
|
title: 'Test normalizare',
|
||||||
|
style: 'classic',
|
||||||
|
color: '#6d28d9',
|
||||||
|
charName: 'X',
|
||||||
|
story: 'S',
|
||||||
|
finalMessage: 'F',
|
||||||
|
puzzles: [
|
||||||
|
{ title: 'P1', type: 'INVALID_TYPE', question: 'Q?', answer: 'A',
|
||||||
|
tfAnswer: 'Adevarat', choices: 42, hint: null, letter: 'X' }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.locator('#fileLoad').setInputFiles(tmpPath);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
} finally {
|
||||||
|
unlinkSync(tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builder-ul trebuie sa ramana functional (nu crash)
|
||||||
|
await expect(page.locator('#gTitle')).toBeVisible();
|
||||||
|
await expect(page.locator('#addPuzzle')).toBeVisible();
|
||||||
|
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SECTIUNEA 3 — CAMPANIE E2E
|
||||||
|
//
|
||||||
|
// *** SKIP pana cand integrator anunta implementarea gata ***
|
||||||
|
// Dupa: schimba `test.skip(true, ...)` → `test.skip(false)` sau sterge linia.
|
||||||
|
//
|
||||||
|
// Contractul documentat in plan:
|
||||||
|
// - gameCampaign(cfg) genereaza HTML cu iframe per camera
|
||||||
|
// - Fiecare camera apeleaza parent.nextRoom({idx, stars, letter})
|
||||||
|
// - parent.roomReady() semnaledaza montarea cu succes
|
||||||
|
// - parent.roomError(idx, msg) declanseaza skip cu 0 stele
|
||||||
|
// - Timeout 4s fara roomReady → camera moarta → skip
|
||||||
|
// - CFG._campaign = {idx, total, stars, letters, deadline} in fiecare camera
|
||||||
|
// - Replace token: TPL.replace('__CFG__', () => json) (D1: evita $&)
|
||||||
|
// - safeStore (try/catch) pentru resume (D3)
|
||||||
|
// - Hash djb2 peste CFG embedat la export (D11)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe('Campanie E2E @campanie', () => {
|
||||||
|
|
||||||
|
/** Helper: genereaza un cfg de campanie cu N puzzle-uri, stiluri rotite. */
|
||||||
|
function campaignCfg(n = 5) {
|
||||||
|
const styles = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
return {
|
||||||
|
title: 'Test Campanie ' + n, player: 'Tester', color: '#6d28d9',
|
||||||
|
style: 'campaign', charName: 'Alex',
|
||||||
|
story: 'O campanie de test cu ' + n + ' camere.',
|
||||||
|
finalMessage: 'Ai terminat campania!',
|
||||||
|
puzzles: Array.from({ length: n }, (_, i) => ({
|
||||||
|
title: 'Camera ' + (i + 1),
|
||||||
|
type: 'free',
|
||||||
|
question: 'Raspunde ' + (i + 1),
|
||||||
|
answer: 'r' + (i + 1),
|
||||||
|
tfAnswer: 'Adevarat',
|
||||||
|
choices: '',
|
||||||
|
hint: '',
|
||||||
|
letter: String.fromCharCode(65 + (i % 26)),
|
||||||
|
style: styles[i % 5]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('campanie E2E — intro → camere cu stiluri rotite → final cu stele+litere+cuvant corect @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta gameCampaign de la integrator — ster skip dupa');
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
const cfg = campaignCfg(5);
|
||||||
|
const html = await page.evaluate((c) => {
|
||||||
|
if (typeof gameCampaign !== 'function') throw new Error('gameCampaign not yet');
|
||||||
|
return gameHTML(c);
|
||||||
|
}, cfg);
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
await gp.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// Intro campanie
|
||||||
|
await gp.locator('button:text("Incepe aventura")').click();
|
||||||
|
|
||||||
|
// Parcurge 5 camere, fiecare in stilul ei rotat
|
||||||
|
const styles = ['classic', 'terminal', 'arcade', 'chat', 'point'];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const answer = 'r' + (i + 1);
|
||||||
|
const style = styles[i % 5];
|
||||||
|
|
||||||
|
// Asteapta roomReady → camera montata
|
||||||
|
await gp.waitForFunction(
|
||||||
|
() => document.querySelector('[data-room-ready="true"]') !== null ||
|
||||||
|
document.querySelector('iframe.room-ready') !== null,
|
||||||
|
{ timeout: 8000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Raspunde in camera curenta (prin iframe)
|
||||||
|
const iframeLocator = gp.frameLocator('iframe[data-room]').last();
|
||||||
|
|
||||||
|
if (style === 'classic') {
|
||||||
|
await iframeLocator.locator('#btnStart').click();
|
||||||
|
await iframeLocator.locator('#answers input').fill(answer);
|
||||||
|
await iframeLocator.locator('#answers button:text("Verifica")').click();
|
||||||
|
} else if (style === 'terminal') {
|
||||||
|
await gp.waitForFunction(() => true); // terminal necesita interactiune specifica
|
||||||
|
await iframeLocator.locator('#cmd').fill(answer);
|
||||||
|
await iframeLocator.locator('#cmd').press('Enter');
|
||||||
|
}
|
||||||
|
// Alte stiluri: similar
|
||||||
|
|
||||||
|
// Asteapta nextRoom apelat → apare coridorul
|
||||||
|
if (i < 4) {
|
||||||
|
await gp.waitForSelector('button:text("Deschide usa")', { timeout: 10000 });
|
||||||
|
// Verifica stele si litera in coridor
|
||||||
|
await expect(gp.locator('[data-stars], .stars, .stele')).toBeVisible({ timeout: 3000 });
|
||||||
|
// Click "Deschide usa"
|
||||||
|
await gp.locator('button:text("Deschide usa")').click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ecranul final
|
||||||
|
await expect(gp.locator('#fOverlay, [data-final]')).toBeVisible({ timeout: 10000 });
|
||||||
|
// Cuvantul magic = ABCDE (primele 5 litere)
|
||||||
|
const finalText = await gp.content();
|
||||||
|
expect(finalText).toMatch(/A.*B.*C.*D.*E/);
|
||||||
|
|
||||||
|
expect(gameErrors, 'Game errors: ' + gameErrors.join('\n')).toHaveLength(0);
|
||||||
|
expect(errors, 'Builder errors: ' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume — reload mid-campanie returneaza la coridor (safeStore D3+D11) @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta safeStore+hash din T7 de la integrator');
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
const cfg = campaignCfg(3);
|
||||||
|
const html = await page.evaluate((c) => gameHTML(c), cfg);
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
await gp.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// Start si completa camera 1 (progreseaza dincolo de ea)
|
||||||
|
await gp.locator('button:text("Incepe aventura")').click();
|
||||||
|
// ... rezolva camera 1 ...
|
||||||
|
await gp.waitForSelector('button:text("Deschide usa")', { timeout: 12000 });
|
||||||
|
|
||||||
|
// Reload mid-campanie (inainte de camera 2)
|
||||||
|
await gp.reload();
|
||||||
|
|
||||||
|
// Trebuie sa se reia la coridor, NU la intro
|
||||||
|
await expect(gp.locator('button:text("Deschide usa")')).toBeVisible({ timeout: 5000 });
|
||||||
|
// Intro-ul NU trebuie sa fie vizibil
|
||||||
|
const hasIntro = await gp.locator('button:text("Incepe aventura")').isVisible();
|
||||||
|
expect(hasIntro).toBe(false);
|
||||||
|
|
||||||
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('camera moarta — template stricat → skip 0 stele + cod eroare vizibil @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta roomReady/roomError+timeout T3 de la integrator');
|
||||||
|
// Contractul: un template de camera care arunca eroare inainte de roomReady
|
||||||
|
// → coridorul afiseaza "usa intepenita" cu:
|
||||||
|
// - 0 stele
|
||||||
|
// - cod eroare "stil·idx" monospace mic
|
||||||
|
// - buton "Sari la camera urmatoare"
|
||||||
|
//
|
||||||
|
// La export final: litera camerei sarite = dala goala cu lakat.
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
const cfg = campaignCfg(3);
|
||||||
|
const html = await page.evaluate((c) => gameHTML(c), cfg);
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
await gp.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
await gp.locator('button:text("Incepe aventura")').click();
|
||||||
|
|
||||||
|
// Injecteaza o eroare in prima camera dupa montare
|
||||||
|
await gp.waitForFunction(
|
||||||
|
() => document.querySelector('iframe[data-room]') !== null,
|
||||||
|
{ timeout: 6000 }
|
||||||
|
);
|
||||||
|
await gp.evaluate(() => {
|
||||||
|
const iframe = document.querySelector('iframe[data-room]');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.dispatchEvent(new ErrorEvent('error', {
|
||||||
|
message: 'Template stricat', error: new Error('Template stricat')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coridorul trebuie sa arate "usa intepenita" cu 0 stele si cod eroare
|
||||||
|
await expect(gp.locator(':text("intepenita"), :text("Sari")')).toBeVisible({ timeout: 8000 });
|
||||||
|
const corridorText = await gp.content();
|
||||||
|
expect(corridorText).toMatch(/classic[·.]0|terminal[·.]0|arcade[·.]0/i); // cod eroare
|
||||||
|
|
||||||
|
// Stars = 0 pentru camera sarita
|
||||||
|
await expect(gp.locator(':text("0 ★"), :text("0/")').first()).toBeVisible();
|
||||||
|
|
||||||
|
expect(gameErrors.filter(e => !e.includes('Template stricat')), gameErrors.join('\n'))
|
||||||
|
.toHaveLength(0);
|
||||||
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eroare post-ready — acelasi skip ca camera moarta @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta roomError semantic ORICAND T3+D5 de la integrator');
|
||||||
|
// Camera apeleaza roomReady() dar arunca o eroare async mai tarziu
|
||||||
|
// → acelasi overlay "usa intepenita" ca si camera moarta
|
||||||
|
// Specificat in plan: "roomError are semantica ORICAND — si post-ready"
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dublu-click "Deschide usa" — idempotent (fara stare corupta) @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta guard idempotenta T4+D5 de la integrator');
|
||||||
|
// Doua click-uri rapide pe "Deschide usa" NU trebuie:
|
||||||
|
// - sa monteze doua camere
|
||||||
|
// - sa corupte idx-ul activ
|
||||||
|
// - sa dupleze apelurile nextRoom
|
||||||
|
// Specificat in plan: butonul dezactivat dupa primul click; nextRoom ignorat pt idx deja incheiat
|
||||||
|
});
|
||||||
|
|
||||||
|
test('intrebare cu $/$& in text — camera se monteaza corect (D1 replace-functie) @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta replace(TOKEN, () => json) D1 de la integrator');
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
// Puzzle cu $ si & in intrebare (ar corupe JSON-ul daca replace e string)
|
||||||
|
const cfg = {
|
||||||
|
...campaignCfg(1),
|
||||||
|
puzzles: [{
|
||||||
|
title: 'Dollar test', type: 'free',
|
||||||
|
question: 'Costa $10.00 & $& mai mult?',
|
||||||
|
answer: 'da', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'X',
|
||||||
|
style: 'classic'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = await page.evaluate((c) => gameHTML(c), cfg);
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
await gp.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// Asteapta montarea camerei (roomReady)
|
||||||
|
await gp.waitForFunction(
|
||||||
|
() => document.querySelector('iframe[data-room]')?.contentDocument != null,
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// CFG.puzzles[0].question trebuie sa fie intact
|
||||||
|
const question = await gp.evaluate(() => {
|
||||||
|
const iframe = document.querySelector('iframe[data-room]');
|
||||||
|
return iframe?.contentWindow?.CFG?.puzzles?.[0]?.question ?? '';
|
||||||
|
});
|
||||||
|
expect(question).toContain('$10.00');
|
||||||
|
expect(question).toContain('$&');
|
||||||
|
|
||||||
|
expect(gameErrors, gameErrors.join('\n')).toHaveLength(0);
|
||||||
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('campanie 8+ camere — beep functional pana la final @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta gameCampaign + parent.beep D2 de la integrator');
|
||||||
|
// Cu 8 camere (peste limita de 5 stiluri), beep() trebuie sa functioneze
|
||||||
|
// in toate camerele fara sa depaseasca limita de AudioContext a browser-ului.
|
||||||
|
// Specificat in plan: audio detinut de parinte; camerele apeleaza parent.beep(ok).
|
||||||
|
});
|
||||||
|
|
||||||
|
test('campanie: 320x568 fara overflow orizontal (chrome 40px + calc(100vh-chrome)) @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.skip(true, 'Asteapta chrome bar + buget vertical T6+TD4 de la integrator');
|
||||||
|
await page.setViewportSize({ width: 320, height: 568 });
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
const cfg = campaignCfg(2);
|
||||||
|
const html = await page.evaluate((c) => gameHTML(c), cfg);
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
await gp.setViewportSize({ width: 320, height: 568 });
|
||||||
|
const gpErrors = trackErrors(gp);
|
||||||
|
await gp.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||||
|
await gp.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Zero scroll orizontal (§Design pct. 11)
|
||||||
|
const overflow = await gp.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth >
|
||||||
|
document.documentElement.clientWidth + 1
|
||||||
|
);
|
||||||
|
expect(overflow, 'Overflow orizontal la 320x568 in campanie').toBe(false);
|
||||||
|
|
||||||
|
// Chrome 40px sub 600px (§Design pct. 11)
|
||||||
|
const chromeHeight = await gp.evaluate(() => {
|
||||||
|
const chrome = document.querySelector('[data-chrome], .campaign-chrome, #chrome');
|
||||||
|
return chrome ? chrome.getBoundingClientRect().height : null;
|
||||||
|
});
|
||||||
|
if (chromeHeight !== null) {
|
||||||
|
expect(chromeHeight, 'Chrome > 40px la 320px').toBeLessThanOrEqual(40);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(gpErrors, gpErrors.join('\n')).toHaveLength(0);
|
||||||
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user