` — oyun tek başına açıldıysa * 3. `navigator.language` * 4. 'en' — kaynak dil * * Eksik anahtar sessizce İngilizceye düşer; oyun hiçbir dilde kırılmaz. */ const LOCALES = ['en', 'tr', 'de', 'es', 'pt']; const DEFAULT_LOCALE = 'en'; function resolveLocale(catalogs) { const known = (code) => { if (!code) return null; const base = String(code).toLowerCase().split(/[-_]/)[0]; return catalogs[base] ? base : null; }; return known(globalThis.__ET_LOCALE__) ?? known(globalThis.document?.documentElement?.lang) ?? known(globalThis.navigator?.language) ?? DEFAULT_LOCALE; } /** * @param {Record>} catalogs { en: {...}, tr: {...} } * @param {string} [locale] */ function createT(catalogs, locale = resolveLocale(catalogs)) { const cat = catalogs[locale] ?? catalogs[DEFAULT_LOCALE]; const fallback = catalogs[DEFAULT_LOCALE]; const t = (key, params) => { let msg = cat[key]; if (msg === undefined) msg = fallback[key]; if (msg === undefined) return key; // görünür kalsın if (!params) return msg; return msg.replace(/\{(\w+)\}/g, (m, k) => (params[k] ?? m)); }; t.locale = locale; /** * HTML'deki sabit metinleri çevirir. * Round *
`; this.el = { title: this.root.querySelector('[data-r="title"]'), hint: this.root.querySelector('[data-r="hint"]'), listenK: this.root.querySelector('[data-r="listen-k"]'), listenLabel: this.root.querySelector('[data-r="listen-label"]'), badge: this.root.querySelector('[data-r="badge"]'), ref: this.root.querySelector('[data-r="ref"]'), variants: this.root.querySelector('[data-r="variants"]'), helpers: this.root.querySelector('[data-r="helpers"]'), auto: this.root.querySelector('[data-r="auto"]'), seq: this.root.querySelector('[data-r="seq"]'), visual: this.root.querySelector('[data-r="visual"]'), posSec: this.root.querySelector('[data-r="pos-sec"]'), posK: this.root.querySelector('[data-r="pos-k"]'), posV: this.root.querySelector('[data-r="pos-v"]'), posray: this.root.querySelector('[data-r="posray"]'), intSec: this.root.querySelector('[data-r="int-sec"]'), intK: this.root.querySelector('[data-r="int-k"]'), intV: this.root.querySelector('[data-r="int-v"]'), intray: this.root.querySelector('[data-r="intray"]'), intTrack: this.root.querySelector('[data-r="int-track"]'), intFill: this.root.querySelector('[data-r="int-fill"]'), intKnob: this.root.querySelector('[data-r="int-knob"]'), intEndLo: this.root.querySelector('[data-r="int-end-lo"]'), intEndHi: this.root.querySelector('[data-r="int-end-hi"]'), matSec: this.root.querySelector('[data-r="mat-sec"]'), matK: this.root.querySelector('[data-r="mat-k"]'), matV: this.root.querySelector('[data-r="mat-v"]'), chips: this.root.querySelector('[data-r="chips"]'), matNote: this.root.querySelector('[data-r="mat-note"]'), }; this._academyBuild(); this.el.ref.addEventListener('click', () => this._toggleSelect('ref')); this.el.auto.addEventListener('click', () => this._toggleAuto()); this.el.seq.addEventListener('click', () => this._toggleSequential()); this.root.addEventListener('keydown', (e) => { if (!this.def) return; if(e.target.closest('input,textarea,select,[contenteditable]')) return; if (e.target.closest('[data-r="posray"],[data-r="intray"]')) return; const n = Number(e.key); if (e.key === '0') { e.preventDefault(); this._toggleSelect('ref'); return; } if (n >= 1 && n <= this.def.variants.length) { e.preventDefault(); this._toggleSelect(this.def.variants[n - 1].id); } }); this._bindIntensityDrag(); this._bindPositionDrag(); } /* ═══ YAN KONSOL (2026) ════════════════════════════════════════ Onaylanan maket: solda BÜYÜK SAHNE, sağda 300–360 px KONSOL, üstte ince ders satırı, altta her zaman erişilen "Alıştırmaya geç". sahne (.room__stage) dinle etiketi/rozet · görsel · raylar · açıklama satırı (oda ipucu + "parametrik öğretim görseli" notu) konsol (.academy-room-console) öncelik sırasıyla: DİNLEME ODASI · başlık · dinleme odağı · rehberli/serbest · ses karşılaştırma · malzeme · serbest yardımcılar · kısa değerlendirme kabuğa (RoomShell) dinleme notu + teknik ayrıntılar AÇILIR PANELLERE, üç adım ALT ŞERİDE Düğümler TAŞINIR, yeniden kurulmaz: `this.el.*` önbellekli referansları geçerli kalır (CLAUDE.md tuzağı: yoğunluk rayında innerHTML sıfırlanınca referanslar ölü düğümlere işaret ediyordu). HİÇBİR işlev kaldırılmadı — yalnızca yerleri değişti. */ _academyBuild() { const root = this.root.querySelector('.room'); const head = this.root.querySelector('.room__head'); const stage = this.root.querySelector('.room__stage'); const rows = stage.querySelectorAll('.room__row'); // [0] dinle etiketi, [1] ref + varyantlar /* ── SAHNE ── */ stage.classList.add('academy-room-scene'); this.academyRays = document.createElement('div'); this.academyRays.className = 'academy-room-rays'; this.academyRays.append(this.el.posSec, this.el.intSec); this.academyCaption = document.createElement('div'); this.academyCaption.className = 'academy-room-caption'; const vnote = document.createElement('span'); vnote.className = 'academy-room-vnote'; vnote.textContent = academyRoomCopy('visualNote'); /* "NEREDE İŞİNE YARAR" (kullanıcı kararı, 2026): odanın becerisinin gerçek işteki 2-3 somut durumu. `def.uses` verilmezse gizli — eski oyunlar değişmeden çalışır. Görselin altında, ipucunun hemen ardında: gizli bir panele konsaydı okunmazdı; konsola konsaydı çok düğmeli odaları taşırırdı. */ this.academyUses = document.createElement('div'); this.academyUses.className = 'academy-room-uses'; this.academyUses.hidden = true; this.academyCaption.append(this.el.hint, this.academyUses, vnote); stage.append(this.el.visual, this.academyRays, this.academyCaption); /* ── KONSOL ── */ const con = document.createElement('div'); con.className = 'academy-room-console'; const eyebrow = document.createElement('p'); eyebrow.className = 'academy-room-eyebrow'; eyebrow.textContent = academyRoomCopy('eyebrow'); this.academyCue = document.createElement('p'); this.academyCue.className = 'academy-room-cue'; const modes = document.createElement('div'); modes.className = 'academy-room-modes'; modes.setAttribute('role', 'group'); modes.setAttribute('aria-label', academyRoomCopy('options')); const guided = document.createElement('button'), free = document.createElement('button'); guided.type = free.type = 'button'; guided.textContent = academyRoomCopy('guided'); free.textContent = academyRoomCopy('free'); modes.append(guided, free); /* Rehberli / Serbest: serbest mod GELİŞMİŞ kısmı açar — raylar (görselli odada), eller serbest A/B, sırayla çal. Kullanıcının önceki kararı ("gelişmiş kısmı açsın") aynen korunuyor; yalnızca katlı kutu yok. */ const sync = () => { guided.setAttribute('aria-pressed', String(!this.freeMode)); free.setAttribute('aria-pressed', String(!!this.freeMode)); root.classList.toggle('room--free', !!this.freeMode); this.el.helpers.hidden = !this.freeMode; this._academyPlaceRays(!!this.def?.visual); }; this._academySyncMode = sync; guided.addEventListener('click', () => { this.freeMode = false; sync(); }); free.addEventListener('click', () => { this.freeMode = true; sync(); }); this.academyCompare = rows[1]; this.academyCompare.classList.add('academy-room-compare'); this.academyCompare.setAttribute('role', 'group'); this.academyObservation = document.createElement('div'); this.academyObservation.className = 'academy-room-observation'; const question = document.createElement('p'); question.className = 'academy-room-observation__q'; question.textContent = academyRoomCopy('observe'); const actions = document.createElement('div'); actions.className = 'academy-observation-actions'; this.academyReflection = document.createElement('p'); this.academyReflection.className = 'academy-room-reflection'; this.academyReflection.setAttribute('aria-live', 'polite'); for (const key of ['understood', 'again']) { const b = document.createElement('button'); b.type = 'button'; b.textContent = academyRoomCopy(key); b.setAttribute('aria-pressed', 'false'); b.addEventListener('click', () => { actions.querySelectorAll('button').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); this.academyReflection.textContent = key === 'understood' ? academyRoomCopy('keep') : this.academyCue.textContent; this._markStep(2); }); actions.append(b); } this.academyObservation.append(question, actions, this.academyReflection); /* Ekran okuyucu için seçim bildirimi — görünen hâli teknik panelde. */ this.academySelectedSr = document.createElement('p'); this.academySelectedSr.className = 'academy-sr'; this.academySelectedSr.setAttribute('aria-live', 'polite'); /* Serbest mod yardımcıları (eller serbest A/B, sırayla çal) sahnenin ALTINDAKİ şeritte, rayların yanında: ikisi de yalnızca serbest modun parçası. Konsolda durduklarında 6 düğmeli odalarda konsolu taşırıyordu (ölçüldü: masking 8. oda, 31 px). */ this.academyRays.append(this.el.helpers); con.append(eyebrow, this.el.title, this.academyCue, modes, this.academyCompare, this.el.matSec, this.academyObservation, this.academySelectedSr); head.hidden = true; root.append(con); this.el.helpers.hidden = true; /* ── AÇILIR PANELLER ve ALT ŞERİT içerikleri ── Room kabuğu BİLMEZ; içerikleri kurar, kabuk varsa `open()` onları kabuğun yuvalarına yerleştirir (`_academyMountShell`). Kabuk yoksa konsolun sonunda kalırlar — hiçbir şey kaybolmaz. */ const label = document.createElement('label'); label.className = 'academy-note-label'; label.textContent = academyRoomCopy('note'); this.academyNote = document.createElement('textarea'); this.academyNote.maxLength = 1500; label.append(this.academyNote); const scope = document.createElement('p'); scope.className = 'academy-note-scope'; scope.textContent = academyRoomCopy('noteScope'); this.academyNotes = document.createElement('div'); this.academyNotes.className = 'academy-room-notes'; this.academyNotes.append(label, scope); /* `localStorage` — `sessionStorage` DEĞİL: not sekme kapanınca kayboluyordu ve kaybolan bir not yazmaya değmez. Bu tarayıcıda kalır, sunucuya GİTMEZ (köprü yalnızca oyunun kendi ad alanındaki anahtarları taşıyor) — alttaki kapsam cümlesi bunu açıkça yazar. */ this.academyNote.addEventListener('input', () => { if (this.academyNoteKey) try { localStorage.setItem(this.academyNoteKey, this.academyNote.value); } catch {} }); this.academyTech = document.createElement('dl'); this.academyTech.className = 'academy-room-tech'; const techRow = (key) => { const dt = document.createElement('dt'); dt.textContent = academyRoomCopy(key); const dd = document.createElement('dd'); this.academyTech.append(dt, dd); return dd; }; this.academySelected = techRow('selected'); this.academyTechMaterial = techRow('materialK'); this.academyTechValues = techRow('values'); const tn = document.createElement('p'); tn.className = 'academy-room-tech__note'; tn.textContent = academyRoomCopy('techNote'); this.academyTechWrap = document.createElement('div'); this.academyTechWrap.append(this.academyTech, tn); /* ÜÇ ADIM — maketin akışı. Gösterge SÜS DEĞİL: her adım kullanıcının gerçekten yaptığı bir şeye bağlı (çalma, kendini yoklama). Uydurma bir ilerleme çubuğu olsaydı §4'ü çiğnerdi. */ this.academySteps = document.createElement('ol'); this.academySteps.className = 'academy-room-steps'; this.academySteps.setAttribute('aria-label', academyRoomCopy('stepsAria')); for (const key of ['stepListen', 'stepDescribe', 'stepTry']) { const li = document.createElement('li'); const n = document.createElement('span'); n.className = 'academy-room-steps__n'; const tx = document.createElement('span'); tx.textContent = academyRoomCopy(key); li.append(n, tx); this.academySteps.append(li); } this.academyStep = 0; con.append(this.academyNotes, this.academyTechWrap, this.academySteps); this.freeMode = false; sync(); } /** "Nerede işine yarar" listesi — metin olarak (innerHTML değil), boşsa gizli. */ _renderUses(uses) { const box = this.academyUses; if (!box) return; box.textContent = ''; const items = Array.isArray(uses) ? uses.filter(Boolean) : []; box.hidden = items.length === 0; if (!items.length) return; const title = document.createElement('b'); title.className = 'academy-room-uses__title'; title.textContent = academyRoomCopy('uses'); const ul = document.createElement('ul'); for (const text of items) { const li = document.createElement('li'); li.textContent = text; ul.append(li); } box.append(title, ul); } /* Kabuk (RoomShell) bulunursa not/teknik/adımlar onun yuvalarına geçer. Bir kez yapılır; kabuk yoksa (tek başına test) konsolda kalırlar. */ _academyMountShell() { if (this._shellMounted) return; const shell = this.root.closest('.rshell'); if (!shell) return; const slot = (name) => shell.querySelector(`[data-r="${name}"]`); slot('notes-body')?.append(this.academyNotes); slot('tech-body')?.append(this.academyTechWrap); slot('steps')?.append(this.academySteps); this._shellMounted = true; } /* Raylar HER ZAMAN sahnede. Görselli odada yalnızca SERBEST modda görünür (eskiden gelişmiş kutunun içindeydiler); görseli OLMAYAN odada (compression'ın GR grafiği kullanmayan odaları) sahnenin kendisidirler ve iki modda da görünürler. Hiçbir odada ray kaybolmaz, yalnızca rehberli modda görselin yanında gizlenir. */ _academyPlaceRays(hasVisual) { if (!this.academyRays) return; const stage = this.root.querySelector('.room__stage'); const any = !this.el.posSec.hidden || !this.el.intSec.hidden; const helpers = !!this.freeMode && [this.el.auto, this.el.seq].some((b) => !b.hidden); this.academyRays.hidden = !(helpers || (any && (!hasVisual || this.freeMode))); this.el.posSec.parentElement === this.academyRays && this.academyRays.classList.toggle('has-rays', any && (!hasVisual || !!this.freeMode)); stage.classList.toggle('academy-room-scene--rays', !hasVisual); stage.classList.toggle('academy-room-scene--empty', !hasVisual && !any && this.el.listenK.hidden); } /** * Odayı bir aşama tanımıyla açar. * * @param {{ * chan: string, * title: string, hint: string, * ref: {label:string, params:object, sub?:string}, * variants: Array<{id:string, label:string, params:object, sub?:string, disabled?:string}>, * sweep?: {from:number, to:number, toParams:(v:number)=>object, format:(v:number)=>string, * start?:number, kind?:'position'|'intensity', asButton?:{id:string,label:string}}, * materials?: {list:Array<{id:string,name:string,disabled?:string}>, current:string, label:string}, * fixedMaterial?: {label:string, reason:string}, * visual?: HTMLElement, * positionRay?: PositionRayConfig, * intensityRay?: IntensityRayConfig, * grBadge?: {kind:'equalized', db:number, label:string} | {kind:'text', text:string}, * listenLabel?: string, * onSelect: (params:object) => (void|Promise), * onStop: () => void, * onMaterial?: (id:string) => void, * }} def */ open(def) { this._opening = true; this.close(); this._academyMountShell(); this.def = def; this.selectedId = null; this.root.style.setProperty('--chan', def.chan ?? ''); this.el.title.textContent = def.title; this.academyCue.textContent=academyListeningFocus(def.title); /* NOT ANAHTARI ODANIN KİMLİĞİNDEN kurulur, BAŞLIĞINDAN değil: başlık çevrilmiş metindir, kullanıcı dili değiştirince anahtar da değişir ve not sessizce kaybolurdu. `def.id` dile bağlı değil. */ this.academyNoteKey='academy:room-note:'+location.pathname+':'+(def.id ?? def.title); try{this.academyNote.value=localStorage.getItem(this.academyNoteKey)||'';}catch{this.academyNote.value='';} this.academyStep=0; if(this.academySteps)[...this.academySteps.children].forEach((li,i)=>{li.classList.toggle('is-done',false);li.classList.toggle('is-here',i===0);}); this.academyObservation.querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed','false')); this.academyReflection.textContent=''; this.academySelected.textContent='';this.academySelectedSr.textContent='';this.academyTechValues.textContent='—'; this.el.hint.textContent = def.hint ?? ''; this._renderUses(def.uses); this.el.listenK.hidden = !def.listenLabel && !def.grBadge; this.el.listenLabel.textContent = def.listenLabel ?? ''; this._renderBadge(def.grBadge); this.el.ref.innerHTML = this._btnInner(def.ref.label, def.ref.sub); this.el.ref.dataset.id = 'ref'; this.el.variants.innerHTML = ''; def.variants.forEach((v, i) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'room__variant'; btn.dataset.id = v.id; btn.innerHTML = this._btnInner(v.label, v.sub); btn.setAttribute('aria-pressed', 'false'); btn.title = i < 9 ? `${i + 1}` : ''; btn.addEventListener('click', () => this._toggleSelect(v.id)); this.el.variants.appendChild(btn); }); // Ardışık çalma: brief §3 — üç ve daha fazla varyantlı odalarda. this.el.seq.hidden = def.variants.length < 3; this.el.seq.textContent = this.t('room.sequential'); // Eller serbest A/B: varyant yoksa (yalnızca süpürmeli odalarda, // ör. panning "eksende işaretle") karşılaştıracak ikinci bir şey // yok — düğme gizlenir (YAŞANMIŞ HATA: gizli değildi, tıklamak // hiçbir şey yapmıyordu, kullanıcı "bozuk" sandı). this.el.auto.hidden = def.variants.length === 0; this.el.auto.textContent = this.t('room.auto'); this._sweepAsBtn = null; if (def.sweep?.asButton) { // Gain'in A/B düzeltmesi (brief §4.4): süpürmenin CANLI değeri de // bir DÜĞME olarak görünür, yalnızca çıplak bir ray olarak kalmaz. const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'room__variant room__variant--sweep'; btn.dataset.id = def.sweep.asButton.id; btn.innerHTML = this._btnInner(def.sweep.asButton.label, def.sweep.format(def.sweep.start ?? def.sweep.from)); btn.addEventListener('click', () => { if (this._playing && this.selectedId === def.sweep.asButton.id) { this._stopPlaying(); return; } this._selectSweepButton(); }); this.el.variants.appendChild(btn); this._sweepAsBtn = btn; } this.el.visual.hidden = !def.visual; this.el.visual.innerHTML = ''; if (def.visual) this.el.visual.appendChild(def.visual); this._setupPositionRay(def.positionRay ?? null); this._setupIntensityRay(def.intensityRay ?? null); this._setupMaterials(def); /* Karşılaştırma düğmeleri konsolda alt alta; dörtten fazlaysa iki sütun (ölçüldü: gain 5. oda ve compression 10. oda 6 düğme). */ /* İki sütun YALNIZCA etiketler kısayken. YAŞANMIŞ HATA (1280 × 720 ölçümünde): masking 8. odanın etiketleri uzun ("gerçekten maskeleniyor") ve iki sütunda kelimenin ortasından bölünüyordu ("sad / ece / kısıl / mış") — hem okunmuyor hem konsolu 75 px taşırıyordu. Uzun etiketli çok seçenekli oda tek sütun + sıkı satır. */ const buttons = 1 + def.variants.length + (def.sweep?.asButton ? 1 : 0); const longest = Math.max(0, ...[def.ref, ...def.variants].map((v) => String(v?.label ?? '').length + String(v?.sub ?? '').length * 0.6)); const many = buttons > 4; this.academyCompare.classList.toggle('is-many', many && longest <= 20); this.academyCompare.classList.toggle('is-dense', many && longest > 20); this._academySyncMode(); // Oda açılınca REFERANS çalar — kullanıcı hiçbir şeye basmadan // "burada neyi karşılaştıracağım" duysun. this._select('ref'); this._opening = false; this.emit('open', { id: def.id }); } _btnInner(label, sub) { const l = document.createElement('span'); l.className = 'room__variant-l'; l.textContent = label; if (!sub) return l.outerHTML; return `${l.outerHTML}${ String(sub).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) }`; } /** * GameView'in rozeti SEÇİM SIRASINDA güncellemesi için — çoğu oda * `def.grBadge`'i açılışta bir kez verir (statik, ör. "GR eşitlendi"), * ama bazı odalarda (compression'ın LRA okuması gibi) rozet SEÇİLEN * VARYANTA göre değişir; `def.grBadge` tek seferlik olduğu için bu * durumları karşılayamaz. */ setBadge(badge) { this.el.listenK.hidden = !this.def?.listenLabel && !badge; this._renderBadge(badge); } _renderBadge(badge) { const el = this.el.badge; if (!badge) { el.hidden = true; return; } el.hidden = false; el.classList.toggle('room__badge--ok', badge.kind === 'equalized'); el.classList.toggle('room__badge--text', badge.kind === 'text'); el.textContent = badge.kind === 'equalized' ? `✓ ${badge.label} · ${badge.db.toFixed(1)} dB` : badge.text; } /** * Room'un DIŞINDAN tetiklenen bir seçim değişikliğinde (brief-oda- * genis-duzen.md Faz 2: bir oyunun KENDİ merkez görseli — ör. * frequency'nin `EqVisual`i — kendi sürükleme mantığıyla `onSelect`i * DOĞRUDAN çağırdığında) ref/varyant vurgusunu temizler. Onsuz eski * seçim (genelde "Referans") YANLIŞ aydınlık kalırdı — kullanıcı artık * o değerde değilken düğme hâlâ "buradasın" diyordu. */ clearSelection() { this.selectedId = null; this.el.ref.classList.remove('is-on'); this.el.ref.setAttribute('aria-pressed', 'false'); for (const btn of this.el.variants.querySelectorAll('.room__variant')) { btn.classList.remove('is-on'); btn.setAttribute('aria-pressed', 'false'); } } /** * KULLANICININ tıklaması: çalan sese TEKRAR basmak onu DURDURUR (2026, * kullanıcı istedi: "aynı sese tıklandığında çalan ses dursun"). Oda * sesleri döngüde çalıyor; eskiden durdurmanın tek yolu başka bir sese * geçmek ya da odadan çıkmaktı. Başka bir sese basmak eskisi gibi * kesintisiz geçer. Eller serbest A/B ve sırayla çal `_select`i * doğrudan çağırır — onlar bu kuraldan etkilenmez. */ _toggleSelect(id) { if (this._playing && this.selectedId === id) { this._stopPlaying(); return; } this._select(id); } /** Sesi durdurur; seçim vurgusu kalkar, görsel/raylar son değerde kalır. */ _stopPlaying() { this._stopAuto(); this._stopSequential(); try { this.def?.onStop(); } catch (err) { this.emit('error', err); } this._playing = false; this.clearSelection(); this.academySelectedSr.textContent = academyRoomCopy('stopped'); this.emit('stop'); } /** Oda kapanır: zamanlayıcılar durur, ses durur, seçim sıfırlanır. */ close() { this._stopAuto(); this._stopSequential(); if (this.def) { try { this.def.onStop(); } catch {} } this.def = null; this.selectedId = null; this._playing = false; } /** * Adım göstergesini ilerletir. GERİ GİTMEZ: bir kez dinlediysen * "dinledin"; gösterge bir durum değil, bir geçmiş. */ _markStep(n) { /* Açılışta `open()` kendisi bir referans seçimi yapıyor (`_select('ref')`) ve bu adımı tetikliyordu: kullanıcı henüz hiçbir şey dinlemeden "Dinle" tamamlanmış görünüyordu. Gösterge kullanıcının yaptığını izler, kurulumun yaptığını değil. */ if (this._opening) return; if (!this.academySteps || n <= this.academyStep) return; this.academyStep = n; [...this.academySteps.children].forEach((li, i) => { li.classList.toggle('is-done', i < n); li.classList.toggle('is-here', i === n); }); } _select(id) { if (!this.def) return; this.selectedId = id; const target = id === 'ref' ? this.def.ref : this.def.variants.find((v) => v.id === id); if (!target) return; this.el.ref.classList.toggle('is-on', id === 'ref'); this.el.ref.setAttribute('aria-pressed', String(id === 'ref')); for (const btn of this.el.variants.querySelectorAll('.room__variant')) { const on = btn.dataset.id === id; btn.classList.toggle('is-on', on); btn.setAttribute('aria-pressed', String(on)); } // KESİNTİSİZ GEÇİŞ: sesi durdurup yeniden başlatmak değil, oyunun // kendi zincirinde PARAMETREYİ güncellemek. `onSelect` bunu bilir. try { this.def.onSelect(target.params); } catch (err) { this.emit('error', err); } this._playing = true; this._updateRays(target.params); this.academySelected.textContent=target.label; this.academySelectedSr.textContent=academyRoomCopy('selected')+': '+target.label; this.academyTechValues.textContent=target.sub||'—'; this._markStep(1); this.emit('select', { id }); } /** Gain'in A/B düzeltmesi: "B" düğmesi ray'in O ANKİ değerini seçer. */ _selectSweepButton() { if (!this.def?.sweep) return; this._stopAuto(); this._stopSequential(); this.el.ref.classList.remove('is-on'); this.el.ref.setAttribute('aria-pressed', 'false'); for (const btn of this.el.variants.querySelectorAll('.room__variant')) { const on = btn === this._sweepAsBtn; btn.classList.toggle('is-on', on); btn.setAttribute('aria-pressed', String(on)); } this.selectedId = this.def.sweep.asButton.id; const value = this._sweepValue ?? (this.def.sweep.start ?? this.def.sweep.from); try { this.def.onSelect(this.def.sweep.toParams(value)); } catch (err) { this.emit('error', err); } this._playing = true; } /** * Eller serbest A/B — referans ↔ HER varyantı sırayla dener, ikisi * arasında hep referansa döner: ref → v1 → ref → v2 → ref → v3 → ref… * * YAŞANMIŞ HATA: eskiden yalnızca tıklama anında seçili olan TEK * varyantla (ya da hiçbiri seçili değilse hep ilkiyle) sabit kalıyordu * — 3+ varyantlı bir odada "eller serbest" açıp beklemek kullanıcıyı * sonsuza dek aynı ikiliyle bırakıyordu, ötekiler hiç duyulmuyordu * (bildirilen: "orta sol orta sol yapıyor sadece"). Varyant yoksa * (süpürme odaları) `open()` düğmeyi zaten gizliyor. */ _toggleAuto() { if (this._autoTimer) { this._stopAuto(); return; } const variants = this.def?.variants ?? []; if (!variants.length) return; this._stopSequential(); this.el.auto.classList.add('is-on'); this.el.auto.setAttribute('aria-pressed', 'true'); let vi = 0; let onRef = true; this._autoTimer = setInterval(() => { if (onRef) { this._select(variants[vi].id); vi = (vi + 1) % variants.length; } else { this._select('ref'); } onRef = !onRef; }, 4000); } _stopAuto() { if (this._autoTimer) { clearInterval(this._autoTimer); this._autoTimer = null; } this.el.auto.classList.remove('is-on'); this.el.auto.setAttribute('aria-pressed', 'false'); } _toggleSequential() { if (this._seqTimer) { this._stopSequential(); return; } this._stopAuto(); this.el.seq.classList.add('is-on'); this.el.seq.setAttribute('aria-pressed', 'true'); const ids = ['ref', ...this.def.variants.map((v) => v.id)]; let i = -1; const step = () => { i = (i + 1) % ids.length; this._select(ids[i]); }; step(); this._seqTimer = setInterval(step, 1800); } _stopSequential() { if (this._seqTimer) { clearInterval(this._seqTimer); this._seqTimer = null; } this.el.seq.classList.remove('is-on'); this.el.seq.setAttribute('aria-pressed', 'false'); } /* ═══ malzeme: chip satırı (açılır menü DEĞİL, brief §3.6) ═══ Sınıflandırma odalarında (`def.fixedMaterial`) çip yerine sabit etiket + gerekçe satırı — malzeme SABİT olduğu için seçim anlamsız. */ _setupMaterials(def) { if (def.fixedMaterial) { this.el.matSec.hidden = false; this.el.matK.textContent = this.t('room.material'); this.el.matV.textContent = def.fixedMaterial.label; this.el.chips.hidden = true; this.el.matV.hidden = false; this.el.matNote.hidden = false; this.el.matNote.textContent = def.fixedMaterial.reason; this.academyTechMaterial.textContent = def.fixedMaterial.label; return; } if (!def.materials) { this.el.matSec.hidden = true; this.academyTechMaterial.textContent = '—'; return; } this.el.matSec.hidden = false; this.el.chips.hidden = false; this.el.matNote.hidden = true; this.el.matK.textContent = def.materials.label ?? this.t('room.material'); const current = def.materials.list.find((m) => m.id === def.materials.current); this.el.matV.textContent = current?.name ?? ''; this.academyTechMaterial.textContent = current?.name ?? '—'; /* YAN KONSOL: çip ızgarası (10 malzemede 3 satır) yerine TEK satırlık açılır liste. K1 kuralı korunur — uygun olmayan malzeme listeden ÇIKARILMAZ, seçilemez hâlde durur ve gerekçesi seçeneğin yanında yazar (çipte yalnızca fareyle görülen bir ipucuydu). */ this.el.chips.innerHTML = ''; const sel = document.createElement('select'); sel.className = 'academy-room-select'; sel.setAttribute('aria-label', def.materials.label ?? this.t('room.material')); def.materials.list.forEach((m) => { const o = document.createElement('option'); o.value = m.id; o.textContent = m.disabled ? `${m.name} — ${m.disabled}` : m.name; o.disabled = !!m.disabled; o.selected = m.id === def.materials.current; sel.appendChild(o); }); sel.addEventListener('change', () => this.def?.onMaterial?.(sel.value)); this.el.chips.appendChild(sel); this.el.matV.hidden = true; } /* ═══ konum rayı — "nerede?" (brief §3.5) ═══ Tik + bölge gölgesi + playhead. Domain birimi ve log/linear ölçek oyundan gelir (frequency log Hz, panning/stereo-width linear). */ _setupPositionRay(cfg) { this._posCfg = cfg; this._posMarkers.clear(); const sec = this.el.posSec, ray = this.el.posray; if (!cfg) { sec.hidden = true; ray.innerHTML = ''; return; } sec.hidden = false; this.el.posK.textContent = this.t('room.ray.position'); ray.innerHTML = ''; const span = cfg.log ? Math.log(cfg.max / cfg.min) : (cfg.max - cfg.min); const pctOf = (v) => clamp01(cfg.log ? Math.log(Math.max(cfg.min, v) / cfg.min) / span : (v - cfg.min) / span) * 100; this._posPctOf = pctOf; (cfg.regions ?? []).forEach((r) => { const l = pctOf(r.from), w = pctOf(r.to) - pctOf(r.from); const zone = document.createElement('i'); zone.className = 'pray__zone' + (r.hi ? ' is-hi' : ''); zone.style.left = `${l}%`; zone.style.width = `${w}%`; ray.appendChild(zone); if (r.label) { const nm = document.createElement('span'); nm.className = 'pray__zname' + (r.hi ? ' is-hi' : ''); nm.style.left = `${l + w / 2}%`; nm.textContent = r.label; ray.appendChild(nm); } }); (cfg.ticks ?? []).forEach((v) => { const tick = document.createElement('i'); tick.className = 'pray__tick'; tick.style.left = `${pctOf(v)}%`; ray.appendChild(tick); }); (cfg.endLabels ?? []).forEach(([v, text]) => { const lab = document.createElement('span'); lab.className = 'pray__lab'; const pct = pctOf(v); lab.style.left = `${pct}%`; // YAŞANMIŞ HATA (brief-oda-genis-duzen.md §1.3): tüm etiketler // `translateX(-50%)` ile ORTALANIYORDU — uçtaki bir etiket (ör. // "20k", pct=100) kendi genişliğinin YARISI kadar konteynerin // DIŞINA taşıyor, `.pray{overflow:hidden}` onu kırpıyordu ("20k" // yarım görünüyordu; aynı kusur sol uçta da vardı, `pct=0`da // "20" solda yarım kırpılıyordu). Uç etiketler artık kenara // hizalanır, yalnızca ARADAKİLER ortalanır. lab.style.transform = pct <= 1 ? 'translateX(0)' : pct >= 99 ? 'translateX(-100%)' : 'translateX(-50%)'; lab.textContent = text; ray.appendChild(lab); }); (cfg.markers ?? []).forEach((m) => { const mk = document.createElement('i'); mk.className = 'pray__marker' + (m.chan ? ' is-chan' : ''); mk.style.left = `${pctOf(m.value)}%`; ray.appendChild(mk); this._posMarkers.set(m.id, mk); }); this._posHead = document.createElement('i'); this._posHead.className = 'pray__head'; this._posHead.hidden = true; ray.appendChild(this._posHead); const draggable = this.def?.sweep?.kind === 'position'; ray.setAttribute('role', 'slider'); ray.setAttribute('aria-valuemin', String(cfg.min)); ray.setAttribute('aria-valuemax', String(cfg.max)); ray.tabIndex = draggable ? 0 : -1; ray.classList.toggle('is-drag', draggable); } _updatePositionRay(params) { const cfg = this._posCfg; if (!cfg || !this._posHead) return; const v = cfg.valueOf ? cfg.valueOf(params) : null; if (v === null || v === undefined) { this._posHead.hidden = true; return; } this._posHead.hidden = false; this._posHead.style.left = `${this._posPctOf(v)}%`; this.el.posV.textContent = cfg.format ? cfg.format(v) : String(v); this.el.posray.setAttribute('aria-valuenow', String(v)); this.el.posray.setAttribute('aria-valuetext', cfg.format ? cfg.format(v) : String(v)); } /* ═══ yoğunluk rayı — "ne kadar?" (brief §3.5) ═══ Soldan dolan çubuk + yuvarlak topuz + iki uçta kelime. `stepped` verilirse kademeli (compression — round'lar offline render edildiği için sürekli kaydırma yok, bu GÖRSEL olarak da belli olmalı). */ _setupIntensityRay(cfg) { this._intCfg = cfg; const sec = this.el.intSec; if (!cfg) { // YAŞANMIŞ HATA: burada `intTrack.innerHTML` sıfırlanıyordu — // `.iray__fill`/`.iray__knob` YENİDEN oluşuyor ama `this.el.intFill`/ // `intKnob` (ilk `_build()`'ten kalma) hâlâ ESKİ, artık DOM'dan // KOPMUŞ düğümlere işaret ediyordu. Sonraki bir yoğunluk-rayı odası // `_updateIntensityRay`'de bu ölü düğümlere yazıyordu — görünürde // hiçbir şey değişmiyordu (dolgu hep boş kalıyordu). Bölüm zaten // `hidden` olduğu için DOM'a hiç DOKUNULMAMASI yeterli. sec.hidden = true; this.el.intTrack.querySelectorAll('.iray__stop').forEach((n) => n.remove()); return; } sec.hidden = false; this.el.intK.textContent = this.t('room.ray.intensity'); this.el.intEndLo.textContent = cfg.endWords[0]; this.el.intEndHi.textContent = cfg.endWords[1]; this.el.intray.classList.toggle('is-stepped', !!cfg.stepped); this.el.intTrack.querySelectorAll('.iray__stop').forEach((n) => n.remove()); if (cfg.stepped) { cfg.stepped.forEach((v) => { const t = clamp01((v - cfg.from) / (cfg.to - cfg.from)); const stop = document.createElement('i'); stop.className = 'iray__stop'; stop.style.left = `${t * 100}%`; this.el.intTrack.insertBefore(stop, this.el.intKnob); }); } const draggable = this.def?.sweep?.kind !== 'position' && !!this.def?.sweep; this.el.intray.setAttribute('role', 'slider'); this.el.intTrack.setAttribute('aria-valuemin', String(Math.min(cfg.from, cfg.to))); this.el.intTrack.setAttribute('aria-valuemax', String(Math.max(cfg.from, cfg.to))); this.el.intTrack.tabIndex = draggable ? 0 : -1; this.el.intray.classList.toggle('is-drag', draggable); } _updateIntensityRay(params) { const cfg = this._intCfg; if (!cfg) return; let v = cfg.valueOf ? cfg.valueOf(params) : null; if (v === null || v === undefined) return; // YAŞANMIŞ HATA (brief-oda-genis-duzen.md §1.2): Referans (bypass, // 0 dB — hiç işlem yok) `valueOf`den ray'in gerçek ARALIĞININ // (ör. 3–15 dB) DIŞINDA bir değer (0) döndürüyordu; topuz görsel // olarak "gerçek" uca kenetleniyordu (0, o uca en yakın uçtu) ama // yazı HAM değeri ("0.0 dB") gösteriyordu — topuz "3 dB"de duruyor // görünürken metin "0.0 dB" diyordu. Metin HER ZAMAN topuzun // durduğu yerle AYNI (kırpılmış) değeri okumalı. const lo = Math.min(cfg.from, cfg.to), hi = Math.max(cfg.from, cfg.to); v = Math.max(lo, Math.min(hi, v)); const t = clamp01((v - cfg.from) / (cfg.to - cfg.from)); this.el.intFill.style.width = `${Math.max(2, t * 100)}%`; this.el.intKnob.style.left = `${t * 100}%`; this.el.intV.textContent = cfg.format ? cfg.format(v) : String(v); this.el.intTrack.setAttribute('aria-valuenow', String(v)); this.el.intTrack.setAttribute('aria-valuetext', cfg.format ? cfg.format(v) : String(v)); } _updateRays(params) { this._updatePositionRay(params); this._updateIntensityRay(params); } /* ═══ sürükleme + klavye — ORTAK yardımcı ═══ Konum rayı Shift = BÜYÜK adım kullanır (FreqAxis.js deseni, brief'in istediği yön) — PanAxis.js'in eski Shift=küçük-adım kuralı BURADA yeniden üretilmez, kasıtlı olarak ters. */ _bindPositionDrag() { const ray = this.el.posray; const commit = (v) => { const cfg = this._posCfg; const s = this.def?.sweep; if (!cfg || !s || s.kind !== 'position') return; this._sweepValue = v; this._stopAuto(); this._stopSequential(); this.el.ref.classList.remove('is-on'); for (const btn of this.el.variants.querySelectorAll('.room__variant')) btn.classList.remove('is-on'); this.selectedId = null; const params = s.toParams(v); try { this.def.onSelect(params); } catch (err) { this.emit('error', err); } this._updateRays(params); }; /* * YAŞANMIŞ HATA: burası sürüklenen değeri `this._posCfg` (positionRay — * `min`/`max`) üzerinden hesaplıyordu. `positionRay` GÖSTERİM ekseni * (ör. stereo-width'te 0–190, YÜZDE), `sweep.from`/`sweep.to` ise * PARAMETRENİN kendi ekseni (aynı oyunda 0–1.9, ÇARPAN — 1 = doğal * %100). İkisi panning'de tesadüfen AYNI sayılarla çakışıyordu (-1..1), * bu yüzden orada hiç fark edilmedi; stereo-width'te 100 KAT farklı * olunca sürükleme anında genişlik değeri (ör. 190) doğrudan `width` * parametresine yazılıyordu — ekranda "%19000" gibi anlamsız bir sayı * çıkıyordu ve gerçek ses de o uçtaki mantıksız genişlikte çalıyordu. * Sürüklenen DEĞER her zaman `sweep.from/to`'dan gelir; `positionRay` * yalnızca çizim (tik/uç etiket/playhead konumu) için kullanılır. */ const valueAt = (clientX) => { const r = ray.getBoundingClientRect(); const t = clamp01((clientX - r.left) / r.width); const cfg = this._posCfg, s = this.def?.sweep; if (!s) return 0; return cfg?.log ? s.from * Math.exp(t * Math.log(s.to / s.from)) : s.from + t * (s.to - s.from); }; ray.addEventListener('pointerdown', (e) => { if (!ray.classList.contains('is-drag')) return; ray.setPointerCapture(e.pointerId); commit(valueAt(e.clientX)); const move = (ev) => commit(valueAt(ev.clientX)); ray.addEventListener('pointermove', move); ray.addEventListener('pointerup', () => ray.removeEventListener('pointermove', move), { once: true }); }); ray.addEventListener('keydown', (e) => { if (!ray.classList.contains('is-drag')) return; const s = this.def?.sweep; if (!s) return; const cur = this._sweepValue ?? (s.start ?? s.from); const span = s.to - s.from; const step = (e.shiftKey ? 0.1 : 0.02) * span; let next = cur; if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = cur - step; else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = cur + step; else return; e.preventDefault(); const lo = Math.min(s.from, s.to), hi = Math.max(s.from, s.to); commit(Math.max(lo, Math.min(hi, next))); }); } _bindIntensityDrag() { const track = this.el.intTrack; const commit = (v) => { const cfg = this._intCfg; const s = this.def?.sweep; if (!cfg || !s) return; let value = v; if (cfg.stepped) { value = cfg.stepped.reduce((best, x) => Math.abs(x - v) < Math.abs(best - v) ? x : best, cfg.stepped[0]); } this._sweepValue = value; this._stopAuto(); this._stopSequential(); const params = s.toParams(value); if (this._sweepAsBtn) { this.el.ref.classList.remove('is-on'); this.el.ref.setAttribute('aria-pressed', 'false'); for (const btn of this.el.variants.querySelectorAll('.room__variant')) { const on = btn === this._sweepAsBtn; btn.classList.toggle('is-on', on); btn.setAttribute('aria-pressed', String(on)); } this._sweepAsBtn.querySelector('.room__variant-s').textContent = s.format(value); this.selectedId = s.asButton.id; } else { this.el.ref.classList.remove('is-on'); for (const btn of this.el.variants.querySelectorAll('.room__variant')) btn.classList.remove('is-on'); this.selectedId = null; } try { this.def.onSelect(params); } catch (err) { this.emit('error', err); } this._updateRays(params); }; const valueAt = (clientX) => { const r = track.getBoundingClientRect(); const t = clamp01((clientX - r.left) / r.width); const cfg = this._intCfg; return cfg.from + t * (cfg.to - cfg.from); }; track.addEventListener('pointerdown', (e) => { if (!this.el.intray.classList.contains('is-drag')) return; track.setPointerCapture(e.pointerId); commit(valueAt(e.clientX)); const move = (ev) => commit(valueAt(ev.clientX)); track.addEventListener('pointermove', move); track.addEventListener('pointerup', () => track.removeEventListener('pointermove', move), { once: true }); }); track.addEventListener('keydown', (e) => { if (!this.el.intray.classList.contains('is-drag')) return; const cfg = this._intCfg, s = this.def?.sweep; if (!cfg || !s) return; const cur = this._sweepValue ?? (s.start ?? s.from); const span = cfg.to - cfg.from; const step = (e.shiftKey ? 0.1 : 0.02) * span; let next = cur; if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = cur - step; else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = cur + step; else return; e.preventDefault(); commit(next); }); } } /* ═══════════════════════════════════════════════════════════════ RoomShell — dış KART. Başlık şeridi + (≥820px kalıcı liste | <820px adımlayıcı) + alt şerit. `Room` bunun ürettiği boş `content` mount noktasına kurulur; ikisi birbirini BİLMEZ. ═══════════════════════════════════════════════════════════════ */ class RoomShell extends EventEmitter { constructor(root, { t }) { super(); this.root = root; this.t = t; this._build(); } /* YAN KONSOL kabuğu (2026). Kalıcı sol ders listesi KALDIRILDI: liste "Dersler" düğmesinden açılan bir PANELDE (masaüstünde de, telefonda da aynı). Önceki/sonraki oklar ders satırında kaldı. Dinleme notu ve teknik ayrıntılar da açılır panel; içerikleri Room kurar ve `data-r="notes-body"` / `data-r="tech-body"` yuvalarına yerleştirir. Alt şerit: üç adım + kısa not + "Alıştırmaya geç". */ _build() { this.root.innerHTML = `
`; const q = (n) => this.root.querySelector(`[data-r="${n}"]`); this.el = { dot: q('dot'), game: q('game'), name: q('name'), pos: q('pos'), lessonsK: q('lessons-k'), close: q('close'), prev: q('prev'), next: q('next'), listBtn: q('listbtn'), body: q('body'), content: q('content'), scrim: q('scrim'), drawer: q('drawer'), drawerTitle: q('drawer-title'), drawerX: q('drawer-x'), list: q('list'), progLabel: q('prog-label'), progV: q('prog-v'), progFill: q('prog-fill'), progNote: q('prog-note'), tech: q('tech'), techBtn: q('tech-btn'), techTitle: q('tech-title'), techX: q('tech-x'), notes: q('notes'), notesBtn: q('notes-btn'), notesTitle: q('notes-title'), notesX: q('notes-x'), steps: q('steps'), note: q('note'), enter: q('enter'), }; this._panels = [ { el: this.el.drawer, btn: this.el.listBtn }, { el: this.el.tech, btn: this.el.techBtn }, { el: this.el.notes, btn: this.el.notesBtn }, ]; this.el.close.addEventListener('click', () => { if (this._academyReturn) this._nav.onPick(this._academyReturn.id); else this._nav?.onClose?.(); }); this.el.prev.addEventListener('click', () => this._step(-1)); this.el.next.addEventListener('click', () => this._step(1)); this.el.listBtn.addEventListener('click', () => this._togglePanel(this.el.drawer)); this.el.techBtn.addEventListener('click', () => this._togglePanel(this.el.tech)); this.el.notesBtn.addEventListener('click', () => this._togglePanel(this.el.notes)); for (const x of [this.el.drawerX, this.el.techX, this.el.notesX]) x.addEventListener('click', () => this._closePanels(true)); this.el.scrim.addEventListener('click', () => this._closePanels(true)); this.root.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this._openPanel) { e.stopPropagation(); this._closePanels(true); } }); } /** Paneller birbirini kapatır — aynı anda tek panel açık. */ _togglePanel(el) { const wasOpen = this._openPanel === el; this._closePanels(false); if (wasOpen) return; const p = this._panels.find((x) => x.el === el); el.hidden = false; p.btn.setAttribute('aria-expanded', 'true'); this.el.scrim.hidden = false; this._openPanel = el; // Klavye kullanıcısı paneli açınca içeride başlasın. const first = el === this.el.drawer ? el.querySelector('.rlist__row.is-sel') ?? el.querySelector('button') : el.querySelector('textarea, button:not(.rshell__panel-x), select, input') ?? el.querySelector('button'); first?.focus({ preventScroll: true }); } _closePanels(returnFocus) { const was = this._panels.find((x) => x.el === this._openPanel); for (const p of this._panels) { p.el.hidden = true; p.btn.setAttribute('aria-expanded', 'false'); } this.el.scrim.hidden = true; this._openPanel = null; if (returnFocus && was) was.btn.focus({ preventScroll: true }); } /** * @param {{ * chan:string, gameName:string, * list: Array<{id:string, n:number, title:string, status:'done'|'current'|'locked'|'', reason?:string}>, * currentIndex:number, * onPick:(id:string)=>void, onClose:()=>void, * foot:{note:string, ctaLabel:string, onEnter:()=>void}, * }} nav */ open(nav) { this._nav = nav; this.root.style.setProperty('--chan', nav.chan ?? ''); // Platform içinde sitenin oyun adı (gamePatch enjekte eder); tek başına açılışta oyunun kendi adı. this.el.game.textContent = (typeof window !== 'undefined' && window.__ET_GAME__?.name) || nav.gameName; this.el.lessonsK.textContent = academyRoomCopy('lessons'); this.el.drawerTitle.textContent = academyRoomCopy('lessons'); this.el.techBtn.textContent = academyRoomCopy('tech'); this.el.techTitle.textContent = academyRoomCopy('tech'); this.el.notesBtn.textContent = academyRoomCopy('note'); this.el.notesTitle.textContent = academyRoomCopy('note'); this.el.close.setAttribute('aria-label', this.t('room.screen.close')); this.el.close.textContent = '✕'; for (const x of [this.el.drawerX, this.el.techX, this.el.notesX]) { x.textContent = '✕'; x.setAttribute('aria-label', academyRoomCopy('closePanel')); } this.el.prev.setAttribute('aria-label', academyRoomCopy('prev')); this.el.next.setAttribute('aria-label', academyRoomCopy('next')); this._closePanels(false); this._renderList(); this._renderStepper(); this._renderProgress(); const selected = nav.list[nav.currentIndex], active = nav.list.find((r) => r.status === 'current'); this._academyReturn = active && selected?.id !== active.id ? active : null; this.el.note.textContent = this._academyReturn ? academyRoomCopy('previewNote') : academyRoomCopy('safe'); this.el.enter.textContent = this._academyReturn ? academyRoomCopy('return') : academyRoomCopy('practice'); this.el.enter.onclick = () => { if (this._academyReturn) nav.onPick(this._academyReturn.id); else nav.foot.onEnter(); }; } _renderList() { const nav = this._nav; this.el.list.innerHTML = ''; nav.list.forEach((row, i) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `rlist__row rlist__row--${row.status || 'none'}${i === nav.currentIndex ? ' is-sel' : ''}`; btn.innerHTML = ` ${row.n} ${row.title} ${ row.status === 'done' ? this.t('room.list.done') : row.status === 'current' ? this.t('room.list.here') : row.status === 'locked' ? academyRoomCopy('preview') // `unavailable`: aşamanın kendisi AÇIK ama bu aşamanın hiç // Dinleme Odası YOK (brief-oda-genis-duzen.md §1.1). "kilitli" // İLE AYNI ETİKET DEĞİL: kilitli "henüz sırası gelmedi" der, // bu "bu aşamanın odası yok" der — biri geçici, biri kalıcı. : row.status === 'unavailable' ? this.t('room.list.unavailable') : '' }`; // Kilitli oda YİNE DE AÇILIR — sitenin `.st--locked`'undan farklı. btn.disabled = row.status === 'unavailable'; if (i === nav.currentIndex) btn.setAttribute('aria-current', 'page'); btn.addEventListener('click', () => nav.onPick(row.id)); this.el.list.appendChild(btn); }); } _renderStepper() { const nav = this._nav; const cur = nav.list[nav.currentIndex]; if (!cur) return; this.el.pos.textContent = `${nav.currentIndex + 1} / ${nav.list.length}`; this.el.name.textContent = cur.title; this.el.prev.disabled = nav.currentIndex <= 0; this.el.next.disabled = nav.currentIndex >= nav.list.length - 1; } _step(dir) { const nav = this._nav; let i = nav.currentIndex + dir; while (i >= 0 && i < nav.list.length && nav.list[i].status === 'unavailable') i += dir; if (i < 0 || i >= nav.list.length) return; nav.onPick(nav.list[i].id); } /* Ders panelinin altı: "Odalar N/M" + "dinlemek ilerlemeyi etkilemez". `nav.list`/`currentIndex`ten TÜRETİLİR, yeni bir alan istemez. */ _renderProgress() { const nav = this._nav; const total = nav.list.length; this.el.progLabel.textContent = this.t('room.screen.rooms'); this.el.progV.textContent = `${nav.currentIndex + 1} / ${total}`; this.el.progFill.style.width = `${Math.round((nav.list.filter((r) => r.status === 'done').length / total) * 100)}%`; this.el.progFill.parentElement.hidden = true; this.el.progNote.textContent = this.t('room.screen.progressNote'); } } /* ── src\ui\practice.js ────────────────────── */ /** * practice.js — "Nerede işine yarar" (oda) + "Kendi miksinde dene" (set sonu). * Aşama başına [u1, u2, u3, görev]; saf veri, eksik dil İngilizceye düşer. */ const PRACTICE = { en: { detect: [ 'Hearing whether a compressor on a vocal or bus is doing anything.', 'Noticing an over-compressed sample or loop before you use it.', 'Checking a master that was squashed before it reached you.', 'Put a compressor on a drum loop, level-match on/off, and switch blind: which one is compressed?', ], amount: [ 'Setting "a little glue" on a bus versus heavy squash.', 'Hearing when a vocal compressor takes away too much life.', 'Matching gain reduction to the genre (gentle folk, heavy rock).', 'Compress a vocal with 2, 6 and 12 dB of gain reduction and describe each in one word.', ], dynamics: [ 'Hearing a vocal that jumps out too much in loud words.', 'Deciding whether a performance needs compression or automation.', 'Keeping the dynamic shape of a song while evening it out.', 'Listen to your raw vocal and mark the words that jump out before you add any compressor.', ], 'attack-compare': [ 'Choosing a slow attack to keep drum punch.', 'Using a fast attack to tame a spiky bass or percussion.', 'Hearing why the same compressor sounds "punchy" or "flat".', 'Compress a snare with 1 ms and 30 ms attack at the same reduction; compare the crack.', ], 'attack-classify': [ 'Recognising fast and slow attack settings in a reference mix.', 'Picking attack by ear when presets do not fit.', 'Explaining to others why a drum bus sounds soft or snappy.', 'Listen to the kick in a reference song: fast or slow attack? Try both on your own kick.', ], 'release-compare': [ 'Setting release so the compressor breathes with the tempo.', 'Hearing "pumping" when release is too fast or too slow.', 'Keeping sustain on guitars and pads natural.', 'Compress a drum bus and set release to 50 ms and 400 ms; listen to how the room tail changes.', ], 'attack-vs-release': [ 'Knowing which knob to turn when compression sounds wrong.', 'Separating a lost transient (attack) from a pumping tail (release).', 'Fixing compression faster in a session.', 'Make compression sound bad on purpose twice: once with attack, once with release; note the difference.', ], ratio: [ 'Choosing gentle ratios for vocals and high ratios for limiting peaks.', 'Hearing when a ratio turns compression into limiting.', 'Understanding why 2:1 and 10:1 feel so different.', 'Compress a bass at 2:1 and 8:1 with the same reduction and compare how natural it stays.', ], identify: [ 'Hearing which compressor setting changed between two versions.', 'Reverse-engineering compression on a reference track.', 'Debugging a chain where "something" got worse.', 'Ask a friend to change one setting (threshold, ratio, attack or release) and guess which.', ], estimate: [ 'Estimating settings by ear before looking at the plug-in.', 'Getting close to the right sound on the first try.', 'Recreating the compression of a reference in your own mix.', 'Guess the attack and ratio of a compressed vocal in a reference, then set yours to match.', ], mastery: [ 'Hearing compression in full mixes, not only in solo tracks.', 'Making compression decisions quickly and confidently.', 'Checking an entire mix for over- or under-compression.', 'Listen to your latest mix and list every place where compression is too much or too little.', ], }, tr: { detect: [ 'Vokal ya da bus\'taki bir compressor\'ın bir şey yapıp yapmadığını duymak.', 'Aşırı sıkıştırılmış bir sample\'ı ya da loop\'u kullanmadan önce fark etmek.', 'Sana gelmeden önce ezilmiş bir master\'ı kontrol etmek.', 'Bir davul loop\'una compressor koy, açık/kapalıyı eşit seviyeye getir ve bakmadan geçiş yap: hangisi sıkışık?', ], amount: [ 'Bir bus\'ta "biraz yapıştırma" ile ağır ezmeyi ayırmak.', 'Vokal compressor\'ının fazla canlılık aldığını duymak.', 'Gain reduction\'ı türe göre ayarlamak (yumuşak folk, ağır rock).', 'Bir vokali 2, 6 ve 12 dB gain reduction ile sıkıştır; her birini tek kelimeyle tarif et.', ], dynamics: [ 'Yüksek kelimelerde fazla öne fırlayan bir vokali duymak.', 'Bir performansın compression mı otomasyon mu istediğine karar vermek.', 'Şarkıyı eşitlerken dinamik şeklini korumak.', 'Ham vokalini dinle ve compressor eklemeden önce öne fırlayan kelimeleri işaretle.', ], 'attack-compare': [ 'Davulun vuruşunu korumak için yavaş attack seçmek.', 'Sivri bir bası ya da perküsyonu hızlı attack ile evcilleştirmek.', 'Aynı compressor\'ın neden "vuruşlu" ya da "yassı" duyulduğunu anlamak.', 'Bir snare\'i aynı sıkıştırma miktarında 1 ms ve 30 ms attack ile sıkıştır; çatlamayı karşılaştır.', ], 'attack-classify': [ 'Referans mikste hızlı ve yavaş attack ayarlarını tanımak.', 'Preset uymadığında attack\'ı kulakla seçmek.', 'Bir davul bus\'ının neden yumuşak ya da canlı duyulduğunu anlatmak.', 'Referans şarkıdaki kick\'i dinle: hızlı mı yavaş attack mı? İkisini de kendi kick\'inde dene.', ], 'release-compare': [ 'Release\'i compressor tempoyla nefes alacak şekilde ayarlamak.', 'Release çok hızlı ya da yavaşken oluşan "pompalamayı" duymak.', 'Gitar ve pad\'lerde sustain\'i doğal tutmak.', 'Bir davul bus\'ını sıkıştır, release\'i 50 ms ve 400 ms yap; oda kuyruğunun nasıl değiştiğini dinle.', ], 'attack-vs-release': [ 'Compression kötü duyulduğunda hangi düğmeyi çevireceğini bilmek.', 'Kaybolan transient\'i (attack) pompalayan kuyruktan (release) ayırmak.', 'Seansta compression\'ı daha hızlı düzeltmek.', 'Compression\'ı bilerek iki kez kötü yap: bir kez attack\'la, bir kez release\'le; farkı not et.', ], ratio: [ 'Vokale yumuşak, tepe sınırlamaya yüksek ratio seçmek.', 'Ratio\'nun compression\'ı limiting\'e çevirdiği noktayı duymak.', '2:1 ile 10:1\'in neden bu kadar farklı hissettirdiğini anlamak.', 'Bir bası aynı sıkıştırmayla 2:1 ve 8:1\'de sıkıştır; hangisinin doğal kaldığını karşılaştır.', ], identify: [ 'İki sürüm arasında hangi compressor ayarının değiştiğini duymak.', 'Referans şarkıdaki compression\'ı tersine çözmek.', '"Bir şeyin" kötüleştiği bir zinciri ayıklamak.', 'Bir arkadaşın tek bir ayarı (threshold, ratio, attack ya da release) değiştirsin; hangisi olduğunu tahmin et.', ], estimate: [ 'Eklentiye bakmadan önce ayarları kulakla tahmin etmek.', 'İlk denemede doğru sese yaklaşmak.', 'Referanstaki compression\'ı kendi miksinde yeniden kurmak.', 'Referanstaki sıkıştırılmış vokalin attack ve ratio\'sunu tahmin et, sonra kendininkini ona göre ayarla.', ], mastery: [ 'Compression\'ı yalnız solo kanallarda değil tam mikste duymak.', 'Compression kararlarını hızlı ve emin vermek.', 'Bütün bir miksi fazla ya da az sıkıştırma için taramak.', 'Son miksini dinle ve compression\'ın fazla ya da az olduğu her yeri listele.', ], }, de: { detect: [ 'Hören, ob ein Kompressor auf Gesang oder Bus überhaupt arbeitet.', 'Ein überkomprimiertes Sample oder Loop vor dem Einsatz erkennen.', 'Einen bereits zerquetschten Master prüfen.', 'Kompressor auf einen Drumloop, an/aus pegelangleichen, blind umschalten: Welcher ist komprimiert?', ], amount: [ '„Etwas Kleber“ auf dem Bus von starkem Quetschen unterscheiden.', 'Hören, wenn ein Gesangskompressor zu viel Leben nimmt.', 'Die Gain Reduction dem Genre anpassen (sanfter Folk, harter Rock).', 'Gesang mit 2, 6 und 12 dB Reduktion komprimieren und jede Stufe mit einem Wort beschreiben.', ], dynamics: [ 'Einen Gesang hören, der bei lauten Wörtern zu sehr herausspringt.', 'Entscheiden, ob eine Performance Kompression oder Automation braucht.', 'Die Dynamik eines Songs beim Glätten erhalten.', 'Den rohen Gesang hören und die herausspringenden Wörter markieren, bevor du komprimierst.', ], 'attack-compare': [ 'Langsamen Attack wählen, um den Punch der Drums zu erhalten.', 'Mit schnellem Attack einen spitzen Bass oder Percussion zähmen.', 'Verstehen, warum derselbe Kompressor „punchy“ oder „flach“ klingt.', 'Snare mit 1 ms und 30 ms Attack bei gleicher Reduktion komprimieren; den Knall vergleichen.', ], 'attack-classify': [ 'Schnelle und langsame Attack-Einstellungen in einer Referenz erkennen.', 'Attack nach Gehör wählen, wenn Presets nicht passen.', 'Erklären, warum ein Drumbus weich oder knackig klingt.', 'Kick einer Referenz hören: schneller oder langsamer Attack? Beides an deiner Kick testen.', ], 'release-compare': [ 'Release so setzen, dass der Kompressor im Tempo atmet.', '„Pumpen“ hören, wenn Release zu schnell oder zu langsam ist.', 'Sustain bei Gitarren und Pads natürlich halten.', 'Drumbus komprimieren, Release auf 50 ms und 400 ms; hören, wie sich die Raumfahne ändert.', ], 'attack-vs-release': [ 'Wissen, welchen Regler du drehen musst, wenn Kompression falsch klingt.', 'Verlorenen Transient (Attack) von pumpender Fahne (Release) trennen.', 'Kompression in einer Session schneller korrigieren.', 'Kompression absichtlich zweimal schlecht klingen lassen: einmal per Attack, einmal per Release.', ], ratio: [ 'Sanfte Ratios für Gesang, hohe Ratios zum Begrenzen von Spitzen wählen.', 'Hören, wann eine Ratio Kompression zu Limiting macht.', 'Verstehen, warum 2:1 und 10:1 so verschieden wirken.', 'Bass bei gleicher Reduktion mit 2:1 und 8:1 komprimieren und Natürlichkeit vergleichen.', ], identify: [ 'Hören, welche Kompressor-Einstellung sich zwischen zwei Versionen änderte.', 'Die Kompression einer Referenz zurückverfolgen.', 'Eine Kette debuggen, in der „etwas“ schlechter wurde.', 'Jemand ändert eine Einstellung (Threshold, Ratio, Attack oder Release); rate welche.', ], estimate: [ 'Einstellungen nach Gehör schätzen, bevor du aufs Plug-in schaust.', 'Beim ersten Versuch nahe am richtigen Klang landen.', 'Die Kompression einer Referenz im eigenen Mix nachbauen.', 'Attack und Ratio eines komprimierten Referenzgesangs schätzen und deinen angleichen.', ], mastery: [ 'Kompression im vollen Mix hören, nicht nur solo.', 'Kompressionsentscheidungen schnell und sicher treffen.', 'Einen ganzen Mix auf zu viel oder zu wenig Kompression prüfen.', 'Deinen letzten Mix hören und jede Stelle mit zu viel oder zu wenig Kompression notieren.', ], }, es: { detect: [ 'Oír si un compresor en una voz o bus está haciendo algo.', 'Detectar un sample o loop sobrecomprimido antes de usarlo.', 'Revisar un máster que ya llegó aplastado.', 'Pon un compresor en un loop de batería, iguala on/off y alterna a ciegas: ¿cuál está comprimido?', ], amount: [ 'Distinguir un poco de “pegamento” en un bus de un aplastado fuerte.', 'Oír cuando un compresor le quita demasiada vida a la voz.', 'Ajustar la reducción al género (folk suave, rock duro).', 'Comprime una voz con 2, 6 y 12 dB de reducción y describe cada una con una palabra.', ], dynamics: [ 'Oír una voz que salta demasiado en las palabras fuertes.', 'Decidir si una interpretación pide compresión o automatización.', 'Mantener la forma dinámica de la canción al igualarla.', 'Escucha tu voz sin procesar y marca las palabras que saltan antes de comprimir.', ], 'attack-compare': [ 'Elegir un ataque lento para conservar la pegada de la batería.', 'Usar un ataque rápido para domar un bajo o percusión picudos.', 'Entender por qué el mismo compresor suena “con pegada” o “plano”.', 'Comprime una caja con ataque de 1 ms y de 30 ms con la misma reducción; compara el golpe.', ], 'attack-classify': [ 'Reconocer ataques rápidos y lentos en una mezcla de referencia.', 'Elegir el ataque de oído cuando los presets no sirven.', 'Explicar por qué un bus de batería suena blando o seco.', 'Escucha el bombo de una referencia: ¿ataque rápido o lento? Prueba ambos en tu bombo.', ], 'release-compare': [ 'Ajustar el release para que el compresor respire con el tempo.', 'Oír el “bombeo” cuando el release es muy rápido o muy lento.', 'Mantener natural el sustain de guitarras y pads.', 'Comprime un bus de batería con release de 50 y 400 ms; escucha cómo cambia la cola de la sala.', ], 'attack-vs-release': [ 'Saber qué perilla tocar cuando la compresión suena mal.', 'Separar un transitorio perdido (ataque) de una cola que bombea (release).', 'Arreglar la compresión más rápido en una sesión.', 'Haz que la compresión suene mal a propósito dos veces: con el ataque y con el release.', ], ratio: [ 'Elegir ratios suaves para voces y altos para limitar picos.', 'Oír cuándo un ratio convierte la compresión en limitación.', 'Entender por qué 2:1 y 10:1 se sienten tan distintos.', 'Comprime un bajo a 2:1 y 8:1 con la misma reducción y compara lo natural que suena.', ], identify: [ 'Oír qué ajuste del compresor cambió entre dos versiones.', 'Descifrar la compresión de una referencia.', 'Depurar una cadena en la que “algo” empeoró.', 'Que alguien cambie un ajuste (threshold, ratio, ataque o release) y adivina cuál.', ], estimate: [ 'Estimar ajustes de oído antes de mirar el plugin.', 'Acercarte al sonido correcto al primer intento.', 'Recrear la compresión de una referencia en tu mezcla.', 'Estima el ataque y el ratio de una voz comprimida de referencia y ajusta la tuya igual.', ], mastery: [ 'Oír la compresión en mezclas completas, no solo en solo.', 'Tomar decisiones de compresión rápido y con seguridad.', 'Revisar una mezcla entera por exceso o falta de compresión.', 'Escucha tu última mezcla y anota cada lugar con demasiada o poca compresión.', ], }, pt: { detect: [ 'Ouvir se um compressor numa voz ou bus está fazendo algo.', 'Perceber um sample ou loop comprimido demais antes de usar.', 'Conferir um master que já chegou esmagado.', 'Ponha um compressor num loop de bateria, iguale on/off e alterne às cegas: qual está comprimido?', ], amount: [ 'Diferenciar uma “cola” leve no bus de um esmagamento forte.', 'Ouvir quando o compressor tira vida demais da voz.', 'Ajustar a redução ao gênero (folk suave, rock pesado).', 'Comprima uma voz com 2, 6 e 12 dB de redução e descreva cada uma com uma palavra.', ], dynamics: [ 'Ouvir uma voz que salta demais nas palavras fortes.', 'Decidir se uma performance pede compressão ou automação.', 'Manter a forma dinâmica da música ao nivelar.', 'Ouça a voz crua e marque as palavras que saltam antes de comprimir.', ], 'attack-compare': [ 'Escolher attack lento para manter o punch da bateria.', 'Usar attack rápido para domar um baixo ou percussão pontudos.', 'Entender por que o mesmo compressor soa “com punch” ou “chapado”.', 'Comprima uma caixa com attack de 1 ms e 30 ms na mesma redução; compare o estalo.', ], 'attack-classify': [ 'Reconhecer attacks rápidos e lentos numa mixagem de referência.', 'Escolher o attack de ouvido quando presets não servem.', 'Explicar por que um bus de bateria soa macio ou estalado.', 'Ouça o bumbo de uma referência: attack rápido ou lento? Teste os dois no seu bumbo.', ], 'release-compare': [ 'Ajustar o release para o compressor respirar com o andamento.', 'Ouvir o “bombeamento” quando o release é rápido ou lento demais.', 'Manter natural o sustain de guitarras e pads.', 'Comprima um bus de bateria com release de 50 e 400 ms; ouça como a cauda da sala muda.', ], 'attack-vs-release': [ 'Saber qual botão girar quando a compressão soa errada.', 'Separar um transiente perdido (attack) de uma cauda bombeando (release).', 'Corrigir a compressão mais rápido numa sessão.', 'Faça a compressão soar mal de propósito duas vezes: pelo attack e pelo release.', ], ratio: [ 'Escolher ratios suaves para voz e altos para limitar picos.', 'Ouvir quando o ratio transforma compressão em limitação.', 'Entender por que 2:1 e 10:1 soam tão diferentes.', 'Comprima um baixo em 2:1 e 8:1 com a mesma redução e compare a naturalidade.', ], identify: [ 'Ouvir qual ajuste do compressor mudou entre duas versões.', 'Decifrar a compressão de uma referência.', 'Depurar uma cadeia em que “algo” piorou.', 'Peça para alguém mudar um ajuste (threshold, ratio, attack ou release) e adivinhe qual.', ], estimate: [ 'Estimar ajustes de ouvido antes de olhar o plugin.', 'Chegar perto do som certo na primeira tentativa.', 'Recriar a compressão de uma referência na sua mixagem.', 'Estime o attack e o ratio de uma voz comprimida de referência e ajuste a sua igual.', ], mastery: [ 'Ouvir compressão em mixagens completas, não só em solo.', 'Decidir compressão com rapidez e segurança.', 'Revisar uma mixagem inteira por excesso ou falta de compressão.', 'Ouça sua última mixagem e anote cada ponto com compressão demais ou de menos.', ], }, }; function practiceFor(locale, stageId) { const row = PRACTICE[locale]?.[stageId] ?? PRACTICE.en[stageId]; return row ? { uses: row.slice(0, 3), task: row[3] } : null; } /* ── src\ui\terms.js ────────────────────── */ /** * terms.js — TERİM AÇIKLAMALARI (CLAUDE.md §46, giriş katmanı). * * Kullanıcının hikâyesi: "müzik yapmaya başladığımda tını ne, sweep ne * bilmiyordum." Son yedi oyunda terim zaten açıklanıyordu (§38'in deseni); * ilk on birinde hiç yoktu — yeni gelen tam oraya düşüyor. * * Aynı metin İKİ yerde görünür: * turda sorunun sonunda parantez içinde, YALNIZCA seviye düşükken * odada o aşamanın rozetinde (okunacak bir cümle olarak) * * YALNIZCA ERKEN AŞAMALAR için yazılır: sonraki aşamalar terimi kullanmayı * çoktan öğrenmiş bir kulağa sesleniyor. Metinler `strings.js`e DEĞİL buraya * yazıldı — on bir kataloğun biçimi farklı (§40'ın practice.js kararı). * * Saf veri; bilinmeyen dil İngilizceye düşer. * * AD: `STAGE_TERMS` — `TERMS` DEĞİL. Tone Vocabulary'nin kendi `TERMS` sabiti * var ve düz pakette bütün modüller aynı kapsama düşüyor; derleyici çakışmayı * yakaladı. Ad on bir oyunda da aynı tutuldu. */ const STAGE_TERMS = { tr: { 'detect': 'compressor yüksek anları kısar; seviyeler eşit, vuruşun şekline bak', 'amount': 'gain reduction: compressor\'ün o anda kaç dB kıstığı', 'dynamics': 'dinamik aralık: en sessiz ile en yüksek an arasındaki mesafe', 'attack-compare': 'attack: vuruşa ne kadar hızlı tepki verdiği; hızlı attack ilk anı yakalar', }, en: { 'detect': 'a compressor turns down the loud moments; levels are matched, listen to the shape of the hit', 'amount': 'gain reduction: how many dB the compressor is taking off at that moment', 'dynamics': 'dynamic range: the distance between the quietest and the loudest moment', 'attack-compare': 'attack: how fast it reacts to a hit; a fast attack catches the first instant', }, de: { 'detect': 'ein Kompressor senkt die lauten Momente; Pegel angeglichen — hör auf die Form des Schlags', 'amount': 'Gain Reduction: wie viele dB der Kompressor gerade wegnimmt', 'dynamics': 'Dynamikumfang: der Abstand zwischen dem leisesten und dem lautesten Moment', 'attack-compare': 'Attack: wie schnell er auf einen Schlag reagiert; schnell erwischt den ersten Moment', }, es: { 'detect': 'un compresor baja los momentos altos; los niveles están igualados, escucha la forma del golpe', 'amount': 'gain reduction: cuántos dB está quitando el compresor en ese momento', 'dynamics': 'rango dinámico: la distancia entre el momento más bajo y el más alto', 'attack-compare': 'attack: con qué rapidez reacciona a un golpe; un attack rápido atrapa el primer instante', }, pt: { 'detect': 'um compressor abaixa os momentos altos; os níveis estão iguais, ouça a forma da batida', 'amount': 'gain reduction: quantos dB o compressor está tirando naquele momento', 'dynamics': 'faixa dinâmica: a distância entre o momento mais baixo e o mais alto', 'attack-compare': 'attack: a rapidez com que reage a uma batida; um attack rápido pega o primeiro instante', }, }; /** Terimin turda kaybolduğu seviye — bu seviyeden sonra kullanıcı kelimeyi biliyor. */ const TERM_MAX_LEVEL = 3; /** * @param {string} locale * @param {string} stageId * @param {number|null} level verilirse eşiğin üstünde null döner (tur ekranı); * verilmezse her zaman döner (dinleme odası) * @returns {string|null} */ function termFor(locale, stageId, level = null) { if (level !== null && !(level < TERM_MAX_LEVEL)) return null; return STAGE_TERMS[locale]?.[stageId] ?? STAGE_TERMS.en?.[stageId] ?? null; } /* ── src\ui\set.js ────────────────────── */ /** * Set mekaniği — oyunun BİTİŞİ. * * Oyun eskiden sonsuz akıyordu: round 1, 2, 3… 50, 51… Kullanıcı hiçbir * zaman bir şeyi tamamlamıyor, ne zaman duracağını bilmiyordu. Set bir * bitiş çizgisi koyuyor. * * BU DOSYA ORTAKTIR: `games-src/_shared/ui/set.js` içinde yaşar ve * `tools/sync-material.mjs` ile oyunlara kopyalanır. Oyun başına yeniden * yazılmaz. * * SET BİR ARAYÜZ SARMALAYICISIDIR, VERİ YAPISI DEĞİL. Her round bugün * olduğu gibi tek tek kaydedilir; set yarıda kesilirse hiçbir şey * kaybolmaz, kayıt şeması değişmez, göç gerekmez. Bu yüzden bu dosya * `src/ui/` altında duruyor ve `src/game/` hiçbir şeyden haberdar değil. */ /** * Bir setteki round sayısı. * * 12 başlangıç değeri. Ölçü SÜREdir: kullanıcı bütçesini dakikayla ayırır, * hedef aralık 5–8 dakika. `response_ms` henüz oyunlar tarafından * yazılmadığı için gerçek süreyi ölçemiyoruz; yazılmaya başlayınca bu sayı * ona göre ayarlanır. Uyaranı uzun olan oyunlarda (compression) 8'e inmesi * gerekebilir — o yüzden oyun başına tek sabit, tek yerde. */ const SET_ROUNDS = 12; /** * Nokta şeridi — `SET_ROUNDS` kadar çizgi, tamamlananlar dolu. * * SAYI YAZILMAZ. "4 / 12" yazmak şeridin zaten söylediğini tekrar etmek * olurdu; şeridin işi bakışta okunmak, okumak değil. */ function renderSetStrip(root, done, total = SET_ROUNDS) { if (!root) return; const filled = Math.max(0, Math.min(total, done)); root.innerHTML = Array.from({ length: total }, (_, i) => ``).join(''); const count=document.createElement('span');count.className='academy-set-count';count.textContent=filled+' / '+total;root.append(count); root.setAttribute('aria-valuemin','0'); root.setAttribute('aria-valuenow', String(filled)); root.setAttribute('aria-valuemax', String(total)); } /* ── src\compression\rooms.js ────────────────────── */ /** * rooms.js — Compression'ın ON BİR Dinleme Odası, aşamaların önünde. * * Belge: brief "Dinleme Odaları" (43 oda) §6.2 + §5 (compression'ın İKİ * istisnası). Bu dosya `curriculum.js`'in yalnızca OKUYUCUSUDUR, aşamaya * dokunmaz. Oda puansızdır: `RoundRecord`/`AdaptiveDifficulty`/ * `WeaknessProfile` hiçbirini bu dosya ithal etmez, yapısal olarak * dokunamaz. * * İKİ İSTİSNA (brief §5), diğer dört oyundan FARKLI: * * 1. SÜPÜRME YOK. Round'lar offline render ediliyor (70–300 ms); canlı * kaydırıcı çalışmaz. Yerine ÖNCEDEN RENDER EDİLMİŞ varyant seti + * aralarında anında geçiş — tıpkı oyunun kendi round/ders ekranının * hep yaptığı gibi (`AudioPlayer.toggle()`, bkz. GameView.js). * Kaydırıcı gereken tek yerde (oda 10) SÜREKLİ değil KADEMELİ olur: * birkaç önceden render edilmiş adım arasında buton ile geçilir. * * 2. EŞİT GR KURALI odada da geçerli. `RoundFactory.buildDemo()` bunu * ZATEN uyguluyor (motor tek yerde) — bu dosya yalnızca HANGİ * varyantların hangi kuralı (`matched` / `fixed-threshold`) izleyeceğini * TARİF eder, kuralı yeniden YAZMAZ. * * Her oda tanımı `{ demo, browsable }` döner: * `demo` — `RoundFactory.buildDemo(stage, { demo, materialId })`'a * doğrudan geçecek tarif (material/base/mode/variants). * `browsable` — K3 malzeme seçicisinde gezilebilecek id listesi; * K2 odalarında (7, 9) YOK — malzeme SABİT. */ const roomFmtGr = (db) => `${db} dB GR`; const roomFmtRatio = (r) => `${r}:1`; /** * @param {string} stageId curriculum.js'teki id * @param {{t:Function, material:string}} ctx `material` = o an seçili malzeme * @returns {object|null} { title, hint, demo, browsable, fixedMaterial } */ function roomDefFor(stageId, { t, material }) { switch (stageId) { case 'detect': return { title: t('room.detect.title'), hint: t('room.detect.hint'), browsable: ['kick', 'snare', 'perc', 'drumloop', 'bass', 'complex', 'fullmix'], demo: { material, base: { ratio: 6, attackMs: 6, releaseMs: 150 }, variants: [ { label: t('room.label.dry'), params: null }, { label: t('room.label.heavy'), params: {}, grDb: 13 }, ], }, }; case 'amount': return { title: t('room.amount.title'), hint: t('room.amount.hint'), browsable: ['kick', 'snare', 'perc', 'drumloop', 'bass', 'complex', 'fullmix'], demo: { material, base: { ratio: 6, attackMs: 8, releaseMs: 150 }, variants: [ { label: '0 dB', params: null }, { label: roomFmtGr(3), params: {}, grDb: 3 }, { label: roomFmtGr(6), params: {}, grDb: 6 }, { label: roomFmtGr(12), params: {}, grDb: 12 }, ], }, }; case 'dynamics': return { title: t('room.dynamics.title'), hint: t('room.dynamics.hint'), browsable: ['bass', 'synth', 'drumloop', 'complex', 'fullmix'], showDynamics: true, // GameView her seçimde LRA okur (brief: "crest değil yayılım") demo: { material, base: { ratio: 5, attackMs: 10, releaseMs: 100 }, variants: [ { label: '0 dB', params: null }, { label: roomFmtGr(6), params: {}, grDb: 6 }, { label: roomFmtGr(12), params: {}, grDb: 12 }, ], }, }; case 'attack-compare': return { title: t('room.attack-compare.title'), hint: t('room.attack-compare.hint'), browsable: ['kick', 'snare', 'perc', 'drumloop', 'complex'], // brief-oda-arayuz.md ack #6: GR eğrisi + "GR eşitlendi" rozeti // artık 4/5/6'da da (eskiden yalnızca 7'de vardı). showGrGraph: true, grBadge: true, demo: { material, base: { ratio: 6, releaseMs: 180 }, grDb: 8, variants: [ { label: `${t('room.label.slow')} (80 ms)`, params: { attackMs: 80 } }, { label: `${t('room.label.fast')} (1 ms)`, params: { attackMs: 1 } }, ], }, }; case 'attack-classify': { const b = (id) => ATTACK_BUCKETS.find((x) => x.id === id); return { title: t('room.attack-classify.title'), hint: t('room.attack-classify.hint'), browsable: ['kick', 'snare', 'perc', 'drumloop', 'complex'], showGrGraph: true, grBadge: true, demo: { material, base: { ratio: 6, releaseMs: 190 }, grDb: 8, variants: [ { label: t('room.label.dry'), params: null }, { label: `${b('very-fast').label} (0.8 ms)`, params: { attackMs: 0.8 } }, { label: `${b('fast').label} (5 ms)`, params: { attackMs: 5 } }, { label: `${b('medium').label} (25 ms)`, params: { attackMs: 25 } }, { label: `${b('slow').label} (120 ms)`, params: { attackMs: 120 } }, ], }, }; } case 'release-compare': return { title: t('room.release-compare.title'), hint: t('room.release-compare.hint'), browsable: ['drumloop', 'complex', 'bass', 'synth', 'fullmix'], showGrGraph: true, grBadge: true, demo: { material, base: { ratio: 6, attackMs: 8 }, grDb: 8, variants: [ { label: `${t('room.label.slow')} (800 ms)`, params: { releaseMs: 800 } }, { label: `${t('room.label.fast')} (30 ms)`, params: { releaseMs: 30 } }, ], }, }; case 'attack-vs-release': { // K2 (brief §4): tek ve geniş bantlı malzeme, SABİT — "attack mı // release mi" dersi malzeme değişince bulanıklaşır. GR eğrisi // ZORUNLU (brief §5.3) — GameView her seçimde referansla üst üste çizer. const M = 'drumloop'; return { title: t('room.attack-vs-release.title'), hint: t('room.attack-vs-release.hint'), showGrGraph: true, grBadge: true, // `label` GameView'de doldurulur (materyal kütüphanesinin KENDİ // adından — bu oyunda malzeme adları `materials.list` gibi i18n // anahtarından değil `factory.materials.get(id).name`'den gelir). fixedMaterial: { materialId: M, reason: t('room.cls.materialReason') }, demo: { material: M, base: { ratio: 6, attackMs: 12, releaseMs: 220 }, grDb: 9, variants: [ { label: t('room.reference'), params: {} }, { label: t('room.label.attackChanged'), params: { attackMs: 3 } }, { label: t('room.label.releaseChanged'), params: { releaseMs: 70 } }, ], }, }; } case 'ratio': return { title: t('room.ratio.title'), hint: t('room.ratio.hint'), browsable: ['kick', 'snare', 'drumloop', 'bass', 'complex', 'fullmix'], // Bu odada GR EŞİTLENMEZ — ORTAK EŞİK paylaşılır (mode:'fixed- // threshold' aşağıda). Rozet o yüzden farklı: "GR eşitlendi" değil // "ortak threshold" (brief §4.2, ack #6). fixedThresholdBadge: true, demo: { material, base: { attackMs: 8, releaseMs: 160 }, mode: 'fixed-threshold', anchorRatio: 4, anchorGrDb: 6, variants: [ { label: roomFmtRatio(2), params: { ratio: 2 } }, { label: roomFmtRatio(4), params: { ratio: 4 } }, { label: roomFmtRatio(10), params: { ratio: 10 } }, { label: roomFmtRatio(20), params: { ratio: 20 } }, ], }, }; case 'identify': { // K2 (brief §4): tek ve geniş bantlı malzeme, SABİT — dört adayı // aynı kaynakta karşılaştırmak gerekiyor. "Ratio değişti" varyantı // referansın KENDİ çözülmüş eşiğini paylaşır (`mode:'fixed-threshold'`, // bkz. RoundFactory.buildDemo) — ratio'nun etkisi GR eşitlenince kaybolur. const M = 'complex'; return { title: t('room.identify.title'), hint: t('room.identify.hint'), // `label` GameView'de doldurulur (materyal kütüphanesinin KENDİ // adından — bu oyunda malzeme adları `materials.list` gibi i18n // anahtarından değil `factory.materials.get(id).name`'den gelir). fixedMaterial: { materialId: M, reason: t('room.cls.materialReason') }, demo: { material: M, base: { ratio: 6, attackMs: 12, releaseMs: 220 }, grDb: 8, variants: [ { label: t('room.reference'), params: {} }, { label: t('room.label.attackChanged'), params: { attackMs: 3 } }, { label: t('room.label.releaseChanged'), params: { releaseMs: 60 } }, { label: t('room.label.ratioChanged'), params: { ratio: 14 }, mode: 'fixed-threshold' }, { label: t('room.label.amountChanged'), params: {}, grDb: 14 }, ], }, }; } case 'estimate': return { // "Kademeli gezinme" (brief §3 — bu oyunda süpürme yok): sürekli // kaydırıcı yerine birkaç ÖNCEDEN RENDER EDİLMİŞ adım arasında // buton ile geçilir. Sırayla çal (ardışık) bu odada özellikle // öğretici — CLAUDE.md/brief: "sırayla dinlemek öğretiyor". title: t('room.estimate.title'), hint: t('room.estimate.hint'), browsable: ['kick', 'snare', 'drumloop', 'bass', 'complex'], demo: { material, base: { ratio: 6, attackMs: 12, releaseMs: 220 }, variants: [ { label: '0 dB', params: null }, { label: roomFmtGr(3), params: {}, grDb: 3 }, { label: roomFmtGr(6), params: {}, grDb: 6 }, { label: roomFmtGr(9), params: {}, grDb: 9 }, { label: roomFmtGr(12), params: {}, grDb: 12 }, { label: roomFmtGr(16), params: {}, grDb: 16 }, ], }, }; case 'mastery': return { // Ustalık sınavı — kendi başına yeni bir mekanik değil, önceki 10 // odanın hızlı erişimi. Erişim zaten oda SEÇİCİDE var (aynı seçici // §3 "oyun ekranından dönüş" ihtiyacını da karşılıyor) — burada // yalnızca kısa, karışık bir hatırlatma demosu var. title: t('room.mastery.title'), hint: t('room.mastery.hint'), browsable: ['kick', 'snare', 'perc', 'drumloop', 'bass', 'complex', 'fullmix'], demo: { material, base: { ratio: 6, attackMs: 10, releaseMs: 150 }, variants: [ { label: t('room.label.dry'), params: null }, { label: t('room.label.compressed'), params: {}, grDb: 10 }, ], }, }; default: return null; } } /** Odanın var olduğu tüm aşama id'leri, oynanış sırasıyla (oda seçici için). */ const ROOM_STAGE_IDS = [ 'detect', 'amount', 'dynamics', 'attack-compare', 'attack-classify', 'release-compare', 'attack-vs-release', 'ratio', 'identify', 'estimate', 'mastery', ]; /* ── src\ui\explain.js ────────────────────── */ /** * explain.js — "neden kaçırdın" kural motoru (compression). * * Bu oyunda `explain..no` diye AŞAMA BAŞINA tek bir cümle zaten * vardı. Sorun şuydu: aynı aşamada apayrı hatalar aynı dersi alıyordu — * "attack'ı ters yönde duydum" ile "hiç fark duymadım" aynı cümleyle * karşılanıyordu. Bu dosya aşama kimliğinin YANINA HATANIN ŞEKLİNİ de * koyuyor; aşama cümleleri yedek olarak duruyor. * * SAF: DOM'a, çeviriye, oyuna dokunmaz. Round + sonuç alır, bir çeviri * anahtarı döndürür. * * KURAL (CLAUDE.md §4): her cümle EKRANDA GÖRÜNEN bir sayıya bağlanır. */ /** Bu farkın altındaki iki değer gerçekten zor ayrılır (dB GR). */ const SUBTLE_GR_DB = 1.5; /** * @param {object} round `stageId`, `mode`, `target`, `options`, `correctOptionId` * @param {object} result `correct`, `reason`, `missDistance`, `low`, `high` * @param {{chosenId?:string, targetValue?:number, unit?:string}} ctx * @returns {{key:string, params:object} | null} */ function explainMiss(round, result, ctx = {}) { if (!round || !result || result.correct) return null; const stageId = round.stageId ?? ''; /* ── ARALIK CEVABI (tahmin aşaması) ───────────────────────────── */ if (round.mode === 'estimate' || result.answerType === 'interval') { if (result.reason === 'too_wide') return { key: 'explain.shape.tooWide', params: {} }; const miss = Number.isFinite(result.missDistance) ? result.missDistance : null; if (miss === null || miss <= 0) return null; const truth = Number(ctx.targetValue ?? NaN); if (Number.isFinite(truth) && Number.isFinite(result.high) && Number.isFinite(result.low)) { return truth > result.high ? { key: 'explain.shape.under', params: {} } : { key: 'explain.shape.over', params: {} }; } return { key: 'explain.shape.near', params: {} }; } /* ── İKİ ŞIKLI KARŞILAŞTIRMALAR ─────────────────────────────── Fark küçükse bu bir eşik meselesi, beceri açığı değil — o ayrım kullanıcıya söylenmezse her hata aynı ağırlıkta hissediliyor. */ const gr = Number(round?.target?.grRef ?? NaN); const grTest = Number(round?.target?.grTest ?? NaN); if (Number.isFinite(gr) && Number.isFinite(grTest) && Math.abs(gr - grTest) <= SUBTLE_GR_DB) { return { key: 'explain.shape.subtle', params: {} }; } /* ── "NE DEĞİŞTİ?" — hangi parametreyi hangisiyle karıştırdın ── Bu oyunun en öğretici hatası: karışıklığın YÖNÜ derstir. */ if (stageId === 'identify') { const truth = round.correctOptionId; const given = ctx.chosenId; if (truth && given && truth !== given) { const pair = [truth, given].sort().join('-'); if (pair === 'amount-ratio') return { key: 'explain.mix.amountRatio', params: {} }; if (pair === 'attack-release') return { key: 'explain.mix.attackRelease', params: {} }; if (pair === 'attack-ratio' || pair === 'attack-amount') { return { key: 'explain.mix.attackLevel', params: {} }; } if (pair === 'amount-release' || pair === 'ratio-release') { return { key: 'explain.mix.releaseLevel', params: {} }; } } } /* ── Aşamaya özgü yedek (eski davranış, korunuyor) ───────────── */ return stageId ? { key: `explain.${stageId}.no`, params: {} } : null; } /* ── src\ui\GameView.js ────────────────────── */ /** * Set bitti — siteye dön. * * Oyun platformun içinde bir iframe'de çalışıyor; "bitir" oyunun kendi * giriş ekranına dönmek değil, siteye dönmek demeli. * * Üst pencereyi doğrudan taşımak İŞE YARAMIYOR: tarayıcı, iframe'den * gelen üst düzey gezinmeyi kullanıcı etkileşimi olmadan engelliyor ve * hiçbir hata da vermiyor. O yüzden oyun yalnızca haber veriyor; * gezinmeyi sayfanın kendi scripti yapıyor (`static/play.js`), sync * mesajlarıyla aynı kanaldan ve aynı köken kontrolüyle. * * Tek başına açıldığında parent yoktur, `false` döner ve çağıran eski * davranışı uygular. */ /** Oyun platformun içinde bir iframe'de mi çalışıyor — YAN ETKİSİZ kontrol. */ function inPlatform() { try { return !!(window.parent && window.parent !== window.self); } catch { return false; } } function leaveToSite() { try { if (inPlatform()) { window.parent.postMessage({ type: 'et:leave' }, location.origin); return true; } } catch { /* erişilemiyorsa oyun kendi ekranında kalsın */ } return false; } /** * GameView — tüm DOM burada. Oyun mantığı DOM bilmez; bu dosya da * oyun state'ini asla mutate etmez, sadece olayları dinler ve komut verir. * * Akış: Aşama haritası → Mikro ders → Round → Feedback → Round … */ const h = (tag, props = {}, ...kids) => { const el = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (k === 'class') el.className = v; else if (k === 'html') el.innerHTML = v; else if (k === 'text') el.textContent = v; else if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false && v != null) el.setAttribute(k, v); } for (const kid of kids.flat()) { if (kid == null) continue; el.append(kid.nodeType ? kid : document.createTextNode(String(kid))); } return el; }; /** **kalın** işaretlemesini yapar (mikro derslerde kullanılıyor). */ const md = (s) => s.replace(/\*\*(.+?)\*\*/g, '$1'); /** * Uyaran etiketleri. `ref` çevrilir ("Referans"/"Reference"), kalanı * çevrilmez: Test, A ve B stüdyoda da böyle söylenir. * * Modül düzeyinde `this` YOK — bu yüzden sabit bir nesne değil, çeviriciyi * alan bir fonksiyon. (Otomatik değiştirme burada `this.t` yazıp sayfayı * komple çökertmişti: "Cannot read properties of undefined".) */ const stimLabel = (t) => ({ ref: t('stim.ref'), test: 'Test', A: 'A', B: 'B' }); class GameView { /** * @param {{root:HTMLElement, game:import('../compression/CompressionGame.js').CompressionGame, * player:import('./AudioPlayer.js').AudioPlayer, engine:Object}} opts */ constructor({ root, game, player, engine }) { this.root = root; this.game = game; this.player = player; this.engine = engine; /* Set durumu YALNIZCA burada yaşar: kaç round tamamlandı ve set başladığında hangi aşamadaydık. Oyun mantığı setten habersiz. */ this.setDone = 0; this.setStartStage = null; /* Dil: platform köprüsünün yazdığı window.__ET_LOCALE__ → site neyse o. Oyun tek başına açılırsa tarayıcı diline düşer. */ this.t = createT(STRINGS); /* Ödül sesi. `this.feedback` BU DOSYADA ZATEN geri bildirim panelinin DOM elemanı — aynı ismi kullanmak paneli sessizce ezerdi. `reward`. `measure` ZORUNLU: seviye ölçülerek ayarlanıyor, tepe kazancıyla değil. Bu argüman unutulursa RMS'e düşülür ve ses duyulmaz — yaşanmış bir hata. */ this.reward = createFeedback(engine, { measure: measureLoudness }); this.screen = 'start'; this.round = null; this.selectedOption = null; this.answered = false; this.demo = null; /* ── Dinleme Odası (brief) ───────────────────────────────────── Sınavsız, puansız: bu blok `RoundRecord`/`AdaptiveDifficulty`/ `WeaknessProfile` hiçbirini ithal etmez — yapısal olarak dokunamaz. Compression'ın İKİ istisnası (brief §5): süpürme yok (önceden render edilmiş varyantlar arası `AudioPlayer.toggle()` ile anında geçiş — oyunun KENDİ round/ders mekaniğiyle AYNI), eşit-GR kuralı `RoundFactory.buildDemo()`de tek yerde yaşıyor. */ this.roomDemo = null; // { material, stimuli } — o an açık odanın render'ı this.roomStageId = null; this._roomMaterialByStage = new Map(); this.#build(); this.#bindGame(); this.#bindKeys(); /* Statik metinleri çevir (data-t / data-t-title / data-t-aria) ve ses düğmesini bağla. #build() DOM'u kurduktan SONRA çalışmalı. */ /* BELGENİN KENDİSİNE uygulanır, `#app` elemanına değil: `applyDom` aynı zamanda ``i de doğru dile ayarlıyor. Yalnızca `this.root` verilince lang `en` kalıyordu ve Türkçede CSS'in `text-transform: uppercase`ı yanlış çalışıyordu — "Önizleme" ÖNİZLEME yerine ÖNIZLEME (noktasız I) oluyordu. Diğer beş oyun belgeyi veriyor; bu oyun atlamıştı. */ this.t.applyDom(this.root.ownerDocument ?? this.root); this.#renderSoundToggle(); } /* ═══════════ iskelet ═══════════ */ #build() { this.titleEl = h('h1', { text: this.t('app.title') }); this.subtitleEl = h('p', { class: 'app__subtitle', text: this.t('app.subtitle') }); this.badges = h('div', { class: 'app__badges' }); /* Ödül sesi düğmesi. Bu oyunun DOM'u tamamen JS'te kuruluyor — index.html boş bir
taşıyor — o yüzden düğme de burada doğuyor, HTML'de değil. */ this.soundBtn = h('button', { id: 'btn-sound', class: 'lo__sound sound-toggle', type: 'button', }); this.header = h('header', { class: 'app__header' }, h('div', {}, this.titleEl, this.subtitleEl), h('div', { class: 'app__headright' }, this.badges)); /* — başlangıç ekranı — */ this.volume = h('input', { type: 'range', min: '0', max: '100', value: '75' }); this.volume.addEventListener('input', () => { const v = Number(this.volume.value) / 100; this.engine.setMasterVolumeDb(AUDIO.masterGainDb + 20 * Math.log10(Math.max(v, 0.001))); }); this.startScreen = h('section', { class: 'panel panel--start' }, h('p', { html: this.t('start.blurb') }), h('div', { class: 'row' }, h('label', { class: 'field' }, this.t('field.volume'), this.volume)), h('div', { class: 'actions' }, h('button', { class: 'btn btn--primary btn--lg', onclick: () => this.#start() }, this.t('btn.start')))); /* — aşama haritası — */ this.stageMap = h('div', { class: 'stage-map' }); this.mapScreen = h('section', { class: 'panel' }, h('div', { class: 'question' }, h('div', { class: 'question__text', text: this.t('map.title') }), h('div', { class: 'question__hint', text: this.t('map.hint') })), this.stageMap); /* — mikro ders — */ this.lessonTitle = h('h2', { class: 'lesson__title' }); this.lessonLines = h('ul', { class: 'lesson__lines' }); this.lessonDemo = h('div', { class: 'lesson__demo' }); this.lessonScreen = h('section', { class: 'panel' }, h('div', { class: 'lesson' }, this.lessonTitle, this.lessonLines, this.lessonDemo), h('div', { class: 'actions' }, h('button', { class: 'btn btn--primary btn--lg', onclick: () => this.#enterViaRoom(this.game.stage.id), }, this.t('btn.startPractice')), h('button', { class: 'btn', onclick: () => this.#show('map') }, this.t('btn.backToStages')))); /* ── Dinleme Odası ekranı ── `roomShellRoot` boş bir konteyner; içini ortak `ui/room.js`'in `RoomShell` sınıfı doldurur (kart kabuğu). `RoomShell` KENDİSİ boş bir `content` mount noktası üretir, `Room` oraya kurulur (brief-oda-arayuz.md). `room-screen` sınıfı ortak `room.css`'in dikey ortalama kuralını (`body:has(.room-screen:not([hidden])) .app`) otomatik tetikler. Eski `.grbar` oda-görseli pilotu KALDIRILDI — yeni yoğunluk rayı aynı işi ARIA + klavye ile yapıyor; GR grafiği artık `roomExtra` kardeşi yerine Room'un Görsel yuvasına (`def.visual`) taşınıyor. */ this.roomShellRoot = h('div'); this.roomBuilding = h('div', { class: 'building', hidden: true }, h('span', { class: 'dot' }), this.t('lesson.building')); this.roomScreen = h('section', { class: 'room-screen', hidden: true }, this.roomShellRoot, this.roomBuilding); this.roomShell = new RoomShell(this.roomShellRoot, { t: this.t }); this.room = new Room(this.roomShell.el.content, { t: this.t }); this.room.on('error', (err) => console.error('room', err)); /* ══ OYUN EKRANI — IKIZ (maket 04) ═══════════════════════ Her round'un TAM IKI uyarani var (ref/test ya da A/B), o yuzden yerlesimin kendisi A/B: ekran ikiye bolunur, orta panel yok. Calan yari iceriden cercevelenir. Cevap yuzeyi asamaya gore degisir: 2-4 sutunlu sik satiri, ya da `estimate` asamasinda parametre ekseni (Alan). */ this.setStrip = h('div', { class: 'lo__dots', role: 'progressbar', 'data-t-aria': 'set.progressAria', 'aria-valuemin': '0', }); this.materialLine = h('span', { class: 'lo__mini' }); this.questionText = h('h2', { class: 'lo__q' }); this.questionHint = h('div', { class: 'lo__sub' }); this.halvesEl = h('div', { class: 'ikiz__halves' }); this.answerHost = h('div', {}); this.building = h('div', { class: 'building', hidden: true }, h('span', { class: 'dot' }), this.t('round.building')); this.revealEl = h('div', { class: 'lo__reveal', hidden: true }); this.stageLine = h('div', { class: 'lo__stage', hidden: true }); this.replayHint = h('span', { class: 'lo__hint', 'data-t': 'ikiz.hint' }); this.setExitBtn = h('button', { class: 'lo__hint lo__hint--quiet', type: 'button', onclick: () => this.#endSet(), }, this.t('set.exit')); this.submitBtn = h('button', { class: 'lo__go', type: 'button', onclick: () => void this.#submit(), }, this.t('ui.confirm')); this.nextBtn = h('button', { class: 'lo__go', type: 'button', hidden: true, onclick: () => this.#nextRound(), }, this.t('ui.next')); this.playScreen = h('section', { class: 'layout ikiz' }, h('div', { class: 'lo__top' }, this.setStrip, h('div', { class: 'lo__topright' }, this.materialLine, this.soundBtn)), h('div', { class: 'lo__head' }, h('div', {}, this.questionText, this.questionHint)), h('div', { class: 'lo__mid lo__mid--flush' }, this.halvesEl, this.building, this.answerHost, this.revealEl, this.stageLine), h('div', { class: 'lo__bot' }, h('div', { class: 'lo__botleft' }, this.replayHint, this.setExitBtn), h('div', { class: 'lo__botright' }, this.submitBtn, this.nextBtn))); /* Ayrintili gosterim yerlesimin ALTINDA: gercek ayarlar, gain reduction grafigi ve etiketli tekrar dinleme. Bunlar cevaptan SONRA gelen ogretim malzemesi; oyun ekranini mesgul etmemeliler. */ this.detailEl = h('div', { class: 'detail', hidden: true }); /* ── set sonu ── */ this.setLevelFrom = h('b', {}, '—'); this.setLevelTo = h('b', {}, '—'); this.setRoundsEl = h('p', { class: 'setend__rounds' }); this.setStageEl = h('p', { class: 'setend__stage' }); this.setEndScreen = h('section', { class: 'panel panel--setend', hidden: true }, h('h2', { class: 'setend__title' }, this.t('set.doneTitle')), h('div', { class: 'setend__level' }, h('span', { class: 'setend__level-k' }, this.t('set.level')), h('span', { class: 'setend__level-v' }, this.setLevelFrom, h('i', {}, '→'), this.setLevelTo)), this.setRoundsEl, this.setStageEl, h('div', { class: 'actions' }, h('button', { class: 'btn btn--primary btn--lg', type: 'button', // `#enterViaRoom` merkezî karardır (`#openStage`'in de kullandığı): // aktif aşamanın odası daha ziyaret edilmemişse round'a değil önce // ODAYA gider. Diğer dört oyunda aynı bildirilen sorun için eklendi // ("yeni stage'e geç dedim, dinleme odasına atmadı") — burada set // sonu bir aşama açılışıyla çakışırsa aynı boşluk oluşurdu. onclick: () => this.#enterViaRoom(this.game.stage.id), }, this.t('set.new')), h('button', { class: 'btn', type: 'button', onclick: () => { if (!leaveToSite()) this.#show('map'); }, }, this.t('set.finish'))), h('button', { class: 'btn btn--ghost btn--sm', type: 'button', onclick: () => this.#openRoom(this.game.stage.id), }, this.t('room.screen.back'))); /* - alt bilgi - */ // Ders ekranı (dolayısıyla "dersi tekrar oynat") kaldırıldı — Dinleme // Odası aynı işi yapıyor artık. `map.title` de PLATFORM içindeyken // artık kendi iç aşama listesine DEĞİL, siteye döner (`leaveToSite`, // setEndScreen'in "bitir" düğmesiyle AYNI, zaten var olan güvenli // desen) — standalone modda (site yok) eski davranış (iç liste) kalır. this.footer = h('footer', { class: 'app__footer' }, h('div', { class: 'app__footer-actions' }, h('button', { class: 'btn btn--ghost btn--sm', onclick: () => { if (!leaveToSite()) this.#show('map'); }, }, this.t('map.title')))); this.rangeAxis = new RangeAxis({}); this.grGraph = new GrGraph(); this.root.append(this.header, this.startScreen, this.mapScreen, this.lessonScreen, this.roomScreen, this.playScreen, this.setEndScreen, this.footer); this.playScreen.querySelector('.lo__bot').before(this.detailEl); this.#show('start'); } #applyTheme(mode) { if (mode === 'system') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', mode); try { localStorage.setItem('eartraining:theme', mode); } catch {} this.grGraph.draw(); } /** * Oyunlarda AÇIK TEMA YOK: oyun ekranı aletin kendi zeminidir. * Seçici kaldırıldı, koyu palet kilitli. */ restoreTheme() { this.#applyTheme((()=>{try{return localStorage.getItem('et:theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');}catch{return 'light';}})()); } #show(screen) { this.screen = screen; this.player.stop(); this.startScreen.hidden = screen !== 'start'; this.mapScreen.hidden = screen !== 'map'; this.lessonScreen.hidden = screen !== 'lesson'; this.roomScreen.hidden = screen !== 'room'; this.playScreen.hidden = screen !== 'play'; this.setEndScreen.hidden = screen !== 'setend'; if (screen !== 'play') this.detailEl.hidden = true; // Yerlesim tam alani kaplar: baslik oyun sirasinda yok. this.header.hidden = screen === 'play' || screen === 'room'; this.footer.hidden = screen === 'start' || screen === 'play' || screen === 'room'; if (screen === 'map') this.#renderStageMap(); if (screen !== 'room') { this.room.close(); this.roomDemo = null; } } /* ═══════════ akış ═══════════ */ /** * Platformdan (main.js, `selectStage(wantedStage)` UYGULANDIKTAN SONRA) * çağrılan public giriş noktası — `startScreen`i (ses kilidi açma + * ses seviyesi kaydırıcısı) ATLAR: platform içindeyken ses seviyesi * ZATEN bridge script'i tarafından ayarlanıyor (CLAUDE.md §14) ve * kilit açma için gerçek bir tıklama ZORUNLU DEĞİL — `AudioEngine. * play()`/`playBuffer()` `this.ctx` yoksa kendiliğinden `unlock()` * çağırıyor (bkz. AudioEngine.js), Odadaki İLK gerçek tıklama (referans/ * varyant çalma) bu tetiklemeyi kullanıcı hareketinin İÇİNDE yapar. * `unlock()`'u BURADA (gerçek tıklama olmadan) çağırmak daha KÖTÜ * olurdu: context "suspended" durumda kalıcı oluşur, sonraki `play()` * çağrıları `this.ctx` zaten var diye tekrar denemez — ses HİÇ çalmaz. * * Standalone modda (site yok) HİÇBİR ŞEY yapmaz — `startScreen` kendi * "Start" düğmesiyle eskisi gibi çalışmaya devam eder. */ start() { if (!inPlatform()) return; this.#openStage(this.game.state.currentStageId); } async #start() { await this.player.unlock(); this.engine.setMasterVolumeDb(AUDIO.masterGainDb + 20 * Math.log10(Math.max(Number(this.volume.value) / 100, 0.001))); /* PLATFORM İÇİNDEYSE aşama haritası zaten SİTEDE gösterildi (`/games/compression`) — buradaki harita aynı ekranı ikinci kez soruyordu (bildirilen: "başla dedikten sonra bir daha stage çıkıyor", `?stage=amount` örneği). `selectStage` seçimi zaten `main.js`'te uygulandı; burada yalnızca o aşamaya (ya da kaldığı yere) doğrudan girilir. Harita yalnızca TEK BAŞINA çalışırken (platform yok) giriş noktası olarak kalır. */ if (inPlatform()) { this.#openStage(this.game.state.currentStageId); return; } this.#show('map'); } #renderStageMap() { this.stageMap.innerHTML = ''; let phase = null; for (const s of this.game.stages) { const def = STAGES.find((x) => x.id === s.id); if (def.phase !== phase) { phase = def.phase; this.stageMap.append(h('div', { class: 'stage-phase', text: phase })); } /* Çubuk KANITI gösterir, XP'yi değil: aşama "kaç round oynadın"la değil, "bu zorluğa gelebildin mi"yle açılıyor. */ const pct = s.passLevel === null ? Math.min(100, (s.rounds / 40) * 100) : Math.min(100, ((s.evidence ?? 0) / (s.target ?? 12)) * 100); const bar = h('i'); bar.style.width = `${pct}%`; const row = h('button', { class: `stage-row${s.completed ? ' is-complete' : ''}${s.id === this.game.state.currentStageId ? ' is-current' : ''}`, onclick: () => this.#openStage(s.id), }, h('div', { class: 'stage-row__num', text: s.unlocked ? String(s.index) : '🔒' }), h('div', { class: 'stage-row__body' }, h('div', { class: 'stage-row__title', text: s.title }), h('div', { class: 'stage-row__bar' }, bar)), h('div', { class: 'stage-row__meta', html: s.passLevel === null ? `${s.rounds} round
sv ${s.level.toFixed(1)}` : `${s.evidence ?? 0} / ${s.target}
sv ${s.level.toFixed(1)}` })); row.disabled = !s.unlocked; this.stageMap.append(row); } } #openStage(stageId) { this.game.selectStage(stageId); // Ders ekranı KALDIRILDI (CLAUDE.md §15/16 güncellemesi) — Dinleme // Odası artık aynı işi (tanışma) daha zengin yapıyor, ders çift kapı // oluyordu. `stage.lesson` verisi dokunulmadı (standalone/ileride // gerekebilir), yalnızca BU YOLDAN hiç gösterilmiyor. // // `force`: aşama AÇILIŞI her zaman odadan geçer (bkz. `#enterViaRoom`). this.#enterViaRoom(stageId, true); } /** * Ders → pratik arasındaki durak. * * `force` (aşama AÇILIŞI — `#openStage`, yani siteden giriş ya da aşama * değiştirme): oda HER ZAMAN açılır (kullanıcı kararı, 2026 — odayı bir * kez görmüş kullanıcı bir daha asla göremiyordu). * `force` YOKKEN (set-sonu "Yeni set"): eski kural — yalnızca ziyaret * edilmemişse açılır, yoksa her set başında odaya uğramak pratiği böler. */ #enterViaRoom(stageId, force = false) { if (force || !this.game.hasVisitedRoom(stageId)) this.#openRoom(stageId); else this.#beginPractice(); } #showLesson() { const stage = this.game.stage; if (!stage.lesson) { this.#beginPractice(); return; } this.lessonTitle.textContent = `${stage.index}. ` + this.#lessonText(stage.id, 'title', stage.lesson.title); this.lessonLines.innerHTML = ''; stage.lesson.lines.forEach((line, i) => { this.lessonLines.append(h('li', { html: md(this.#lessonText(stage.id, `l${i + 1}`, line)), })); }); this.lessonDemo.innerHTML = ''; this.demo = null; if (stage.lesson.demo) { this.lessonDemo.append(h('div', { class: 'lesson__demo-label', text: this.t('lesson.listenFirst') })); const row = h('div', { class: 'transport transport--demo' }); this.lessonDemo.append(row, h('div', { class: 'building' }, h('span', { class: 'dot' }), this.t('lesson.building'))); const buildingEl = this.lessonDemo.lastChild; // Ağır render'ı bir sonraki frame'e at ki ekran donmasın setTimeout(() => { try { this.demo = this.game.factory.buildDemo(stage); buildingEl.remove(); for (const s of this.demo.stimuli) { row.append(this.#playButton(s, this.#label(s, 'revealLabelKey', 'revealLabel'))); } } catch (err) { buildingEl.textContent = this.t('lesson.buildFailed'); console.error(err); } }, 30); } this.#show('lesson'); } #beginPractice() { this.game.markLessonSeen(); this.#resetSet(); this.#show('play'); this.#nextRound(); } /* ═══════════ Dinleme Odası (brief) ═══════════════════════════ Sınavsız, puansız: bu blok `RoundRecord`/`AdaptiveDifficulty`/ `WeaknessProfile` hiçbirini ithal etmez — yapısal olarak dokunamaz. Ses zinciri BURADA kurulur; `Room` (ortak) yalnızca DOM/zamanlama. Compression'ın kendi `AudioPlayer.toggle()`/`play()` mekaniği zaten "önceden render edilmiş uyaranı çal" işini yapıyor — round ve ders ekranları da hep bunu kullanıyor (brief §5.1: süpürme yok, hazır varyantlar arası geçiş). */ #defaultRoomMaterial(stageId) { const def = roomDefFor(stageId, { t: this.t, material: 'kick' }); return def?.browsable?.[0] ?? 'kick'; } /** Aşama listesi — durum bilgisiyle (dinlendi/burada/kilitli), brief- oda-arayuz.md §3.2: oyun ekranındaki aşama listesiyle AYNI bilgi mimarisi. Compression'ın `this.game.stages` her aşama için `unlocked`/`completed` booleanlarını DOĞRUDAN taşıyor — beş oyunun en basit şekli (panning id-dizisi, gain/frequency sayaç kullanıyor). */ #roomNavList(selectedId) { return ROOM_STAGE_IDS.map((id) => { const s = this.game.stages.find((x) => x.id === id); const def = roomDefFor(id, { t: this.t, material: 'kick' }); return { id, n: s?.index ?? 0, title: def?.title ?? id, // YAŞANMIŞ HATA: "dinlendi" aşamanın TAMAMLANMIŞ olmasından // türüyordu — odaya hiç girilmemiş bir aşama bile "dinlendi" // görünüyordu. "dinlendi" ODANIN ziyaret edilmesidir. status: id === this.game.state.currentStageId ? 'current' : !s?.unlocked ? 'locked' : this.game.hasVisitedRoom(id) ? 'done' : '', }; }); } #openRoom(stageId) { this.roomStageId = stageId; this.#show('room'); const CHAN = '#3987E5'; // compression kanal rengi — styles.css'teki --chan ile aynı const list = this.#roomNavList(stageId); this.roomShell.open({ chan: CHAN, gameName: this.t('app.title'), list, currentIndex: list.findIndex((r) => r.id === stageId), onPick: (id) => this.#openRoom(id), // ✕ artık oyunun İÇ aşama listesine (`map`, yalnızca standalone modun // giriş noktası — CLAUDE.md §18'in "platform içinde ikinci bir harita // gösterme" kararıyla çelişiyordu) DEĞİL, doğrudan pratiğe döner — // "Aşamaya başla" ile AYNI davranış (`#confirmRoom`). onClose: () => this.#confirmRoom(), foot: { note: this.t('room.screen.note'), ctaLabel: this.t('room.screen.enter'), onEnter: () => this.#confirmRoom(), }, }); this.#buildRoomDemo(stageId); } /** Ağır offline render — bir sonraki frame'e atılır ki ekran donmasın. */ #buildRoomDemo(stageId) { this.room.close(); this.roomDemo = null; this.roomBuilding.hidden = false; setTimeout(() => { if (this.roomStageId !== stageId) return; // kullanıcı arada başka odaya geçti try { const material = this._roomMaterialByStage.get(stageId) ?? this.#defaultRoomMaterial(stageId); this._roomMaterialByStage.set(stageId, material); const def = roomDefFor(stageId, { t: this.t, material }); if (!def) { this.roomBuilding.hidden = true; return; } const demo = this.game.factory.buildDemo({}, { demo: def.demo }); this.roomBuilding.hidden = true; if (!demo) return; this.roomDemo = demo; this._roomShowDynamics = !!def.showDynamics; this._roomShowGr = !!def.showGrGraph; // Rozet (brief §4.2, ack #6): 4-7'de yeşil "GR eşitlendi · X dB" // (demo.grDb ZATEN eşitlenmiş paylaşılan hedef), 8'de (fixed- // threshold) rozet YOK, düz "ortak threshold" metni. const grBadge = def.grBadge && typeof def.demo.grDb === 'number' ? { kind: 'equalized', db: def.demo.grDb, label: this.t('room.badge.equalized') } : def.fixedThresholdBadge ? { kind: 'text', text: this.t('room.badge.commonThreshold') } : undefined; // Yoğunluk rayı (brief tablosu: "hedef GR · 12 → 1 dB · kademeli") // — TÜM odalarda, `stepped` gerçek uyaranların GR değerleri // (round'lar offline render edildiği için sürekli değil). const stepped = demo.stimuli.map((s) => Math.abs(s.grStats?.weightedGrDb ?? s.grStats?.maxGrDb ?? 0)); const intensityRay = { from: 12, to: 1, stepped, endWords: [this.t('room.ray.exaggerated', { v: '12' }), this.t('room.ray.real', { v: '1' })], format: (v) => `${v.toFixed(1)} dB`, valueOf: (params) => { const stim = this.roomDemo?.stimuli?.[params?.index]; return stim ? Math.abs(stim.grStats?.weightedGrDb ?? stim.grStats?.maxGrDb ?? 0) : null; }, }; // Görsel yuvası — GR grafiği (oda 4/5/6/7) `roomExtra` kardeşi // yerine artık Room'un KENDİ Görsel yuvasına (`def.visual`) // ekleniyor; `#roomSelect` her seçimde AYNI düğümü günceller. const visual = this._roomShowGr ? this.grGraph.el : undefined; this.room.open({ uses: practiceFor(this.t.locale, stageId)?.uses, // nerede işine yarar id: stageId, title: def.title, hint: def.hint, ref: { label: this.#label(demo.stimuli[0], 'revealLabelKey', 'revealLabel'), params: { index: 0 } }, variants: demo.stimuli.slice(1).map((s, i) => ({ id: `v${i + 1}`, label: this.#label(s, 'revealLabelKey', 'revealLabel'), params: { index: i + 1 } })), materials: def.browsable ? { current: material, list: def.browsable.map((id) => ({ id, name: this.game.factory.materials.get(id).name })) } : undefined, fixedMaterial: def.fixedMaterial ? { label: this.game.factory.materials.get(def.fixedMaterial.materialId).name, reason: def.fixedMaterial.reason } : undefined, /* Rozet zaten GR için kullanılıyorsa ona dokunma (§46). */ grBadge: grBadge ?? (termFor(this.t.locale, stageId) ? { kind: 'text', text: termFor(this.t.locale, stageId) } : undefined), intensityRay, visual, chan: '#3987E5', // compression kanal rengi — styles.css'teki --chan ile aynı onSelect: (params) => this.#roomSelect(params), onStop: () => this.player.stop(), onMaterial: (id) => { this._roomMaterialByStage.set(stageId, id); this.#buildRoomDemo(stageId); }, }); if (this._roomShowDynamics) this.#showRoomDynamics(0); if (this._roomShowGr) this.#showRoomGr(0); } catch (err) { this.roomBuilding.hidden = false; this.roomBuilding.textContent = this.t('lesson.buildFailed'); console.error(err); } }, 30); } /** * KESİNTİSİZ GEÇİŞ burada compression'a özgü anlamıyla yaşar: canlı * parametre kaydırma yok (brief §5.1 — offline render), ama HAZIR * uyaranlar arasında geçiş round ekranıyla AYNI anlık `player.play()` * çağrısı. Malzeme değişmediği sürece yeniden render YOK. */ #roomSelect(params) { const stim = this.roomDemo?.stimuli?.[params.index]; if (!stim) return; this.player.play(stim); if (this._roomShowDynamics) this.#showRoomDynamics(params.index); if (this._roomShowGr) this.#showRoomGr(params.index); } /** Oda 3 ("dinamikler") — "crest değil yayılım" (brief): LRA okunur. Rozet SEÇİLEN VARYANTA göre değiştiği için `def.grBadge` (statik, açılışta bir kez) yetmez — `Room#setBadge` ile CANLI güncellenir. */ #showRoomDynamics(index) { const stim = this.roomDemo?.stimuli?.[index]; if (!stim) return; const { lra } = measureDynamics(stim.samples, this.player.sampleRate); this.room.setBadge({ kind: 'text', text: `${this.t('room.dynamics.lra')}: ${lra.toFixed(1)} LU` }); } /** Oda 7 ("attack mı release mi") — GR eğrisi ZORUNLU (brief §5.3). Grafik düğümü Room'un Görsel yuvasına `def.visual` ile BİR KEZ eklendi (`#buildRoomDemo`); burada yalnızca İÇERİĞİ güncellenir. */ #showRoomGr(index) { if (!this.roomDemo) return; const ref = this.roomDemo.stimuli[0]; const cur = this.roomDemo.stimuli[index]; const colors = seriesColors(); const series = [{ label: this.#label(ref, 'revealLabelKey', 'revealLabel'), waveform: ref.waveform, grEnvelope: ref.grEnvelope, color: colors[0] }]; if (index !== 0) { series.push({ label: this.#label(cur, 'revealLabelKey', 'revealLabel'), waveform: cur.waveform, grEnvelope: cur.grEnvelope, color: colors[1] }); } this.grGraph.setSeries(series); this.grGraph.draw(); } async #confirmRoom() { const id = this.roomStageId; this.room.close(); this.player.stop(); if (id) { try { await this.game.markRoomVisited(id); } catch {} } this.#beginPractice(); } /* ═══════════ round ═══════════ */ #nextRound() { if (this._setOver) { this.#endSet(); return; } // son sorudan sonra: özet this.building.hidden = false; this.halvesEl.innerHTML = ''; this.answerHost.innerHTML = ''; this.revealEl.hidden = true; this.detailEl.hidden = true; this.nextBtn.hidden = true; this.selectedOption = null; this.answered = false; // Render ağır olabilir; bir frame sonraya at ki "hazırlanıyor" görünsün. setTimeout(() => { try { this.round = this.game.nextRound(); } catch (err) { this.building.textContent = 'Round üretilemedi: ' + err.message; console.error(err); return; } this.building.hidden = true; this.#renderRound(); // sıradakini arka planda hazırla setTimeout(() => this.game.prefetch(), 120); }, 20); } /** Ders ekranı ve tekrar dinleme satırı için basit çal düğmesi. */ #playButton(stimulus, label) { const btn = h('button', { class: 'btn', onclick: () => this.player.toggle(stimulus) }, h('span', { class: 'dot' }), label); btn.dataset.stim = stimulus.id; return btn; } /** İkiz yarısı: harf + etiket. Tıklayınca o uyaran çalar/durur. */ #half(stimulus, label, tag) { const btn = h('button', { class: 'ikiz__half', type: 'button', onclick: () => this.player.toggle(stimulus), }, h('span', { class: 'ikiz__lt', text: label }), h('span', { class: 'ikiz__tag', text: tag })); btn.dataset.stim = stimulus.id; return btn; } /* ── AŞAMA METNİ ÇEVİRİSİ ────────────────────────────────────── `curriculum.js` bir VERİ dosyasıdır ve metinleri TÜRKÇE yazılıdır; çeviriler `strings.js`te aşama kimliğine göre duruyor (`stage..question` gibi, beş dilde). İkisi arasında köprü YOKTU: görünüm doğrudan `stage.question`ı basıyordu, yani oyun İngilizce açıkken bile soru/ipucu/ders satırları TÜRKÇE çıkıyordu. Gerçek tarayıcıda ölçülerek bulundu — altı oyundan yalnızca compression'da vardı, diğer beşi zaten çeviriliydi. Yedek DAİMA curriculum'un kendi metni: bir anahtar eksikse ekran boş kalmaz, Türkçesi görünür. */ #stageText(stageId, suffix, fallback) { const key = `stage.${stageId}.${suffix}`; const v = this.t(key); return v === key ? fallback : v; } #lessonText(stageId, suffix, fallback) { const key = `lesson.${stageId}.${suffix}`; const v = this.t(key); return v === key ? fallback : v; } /** RoundFactory `labelKey`/`revealLabelKey` taşır; yoksa ham etiket. */ #label(obj, keyField = 'labelKey', textField = 'label') { const key = obj?.[keyField]; if (!key) return obj?.[textField] ?? ''; const v = this.t(key); return v === key ? (obj[textField] ?? key) : v; } #renderRound() { const r = this.round; const stage = this.game.stage; /* `RoundFactory#round` aşamanın kendi (Türkçe) metnini round'a KOPYALIYOR, yani `r.question` her zaman dolu — çeviri bu yüzden `r.question`a bakarak değil, round'un `stageId`sinden yapılır. */ const qid = r.stageId ?? stage.id; this.questionText.textContent = this.#stageText(qid, 'question', r.question || stage.question); /* TERİM AÇIKLAMASI (§46): ilk aşamalarda kelimeyi parantez içinde açıklar, seviye yükselince kendiliğinden kaybolur. Seviyeyi okuyamayan oyunda (eski üç oyun) 0 sayılır, yani metin durur — yeni gelen için doğru taraf. */ { const term = termFor(this.t.locale, this.game.stage.id, this.game.difficulty?.level ?? 0); if (term) this.questionText.textContent += ` (${term})`; } /* TEKRAR TURU ROZETİ — soru metninin YANINDA, cevabın yanında değil: hangi şıkkın doğru olduğuna dair ipucu taşımaz (CLAUDE.md §27). */ this.questionText.classList.toggle('has-review', !!r.isReview); this.questionText.querySelectorAll('.round__review').forEach((n) => n.remove()); if (r.isReview) { const badge = document.createElement('span'); badge.className = 'round__review'; badge.textContent = this.t('round.reviewBadge'); this.questionText.append(badge); } this.questionHint.textContent = this.#stageText(qid, 'hint', r.hint ?? stage.hint ?? ''); this.#renderMaterial(); this.#showProgress(false); this.revealEl.hidden = true; this.detailEl.hidden = true; this.nextBtn.hidden = true; this.submitBtn.hidden = false; /* UYARAN: iki yarı. Her round'un tam iki uyaranı var — bölünme yerleşimin kendisi, ayrı bir "transport" satırı yok. */ this.halvesEl.innerHTML = ''; r.transport.forEach((id, i) => { const st = r.byId[id]; /* Büyük harf KONUMU söyler (A / B), küçük etiket ROLÜ ("referans", "test"). Maket 04'teki düzen bu: harf bakışta okunur, rol okunduğunda anlaşılır. `A`/`B` uyaranlarında rol harfle aynı olduğu için etiket dinleme kısayoluna düşer. */ const letter = String.fromCharCode(65 + i); const role = stimLabel(this.t)[id] ?? id; this.halvesEl.append(this.#half(st, letter, role === letter ? this.t('ab.listen') : role)); }); // CEVAP: 2–4 sütunlu şık satırı, ya da parametre ekseni. this.answerHost.innerHTML = ''; if (r.mode === 'estimate') { this.rangeAxis.setAxis(r.axis, { maxWidth: r.maxWidth }); this.rangeAxis.setDisabled(false); this.answerHost.append( h('div', { class: 'alan__note', text: r.fixedNote ?? '' }), this.rangeAxis.el); this.answerHost.className = 'alan__wrap'; this.submitBtn.disabled = false; } else { const row = h('div', { class: 'answers' }); row.dataset.n = String(Math.min(4, Math.max(2, r.options.length))); for (const opt of r.options) { const btn = h('button', { class: 'answers__opt', type: 'button', onclick: () => this.#selectOption(opt.id), }, this.#label(opt), opt.hint ? h('small', { text: opt.hint }) : null); btn.dataset.opt = opt.id; row.append(btn); } this.answerHost.append(row); this.answerHost.className = ''; this.submitBtn.disabled = true; } this.#renderStageLine(); this.#renderBadges(); } #selectOption(id) { if (this.answered) return; this.selectedOption = id; this.answerHost.querySelectorAll('.answers__opt').forEach((b) => { b.classList.toggle('is-on', b.dataset.opt === id); }); this.submitBtn.disabled = false; } async #submit() { if (!this.game.canAnswer) { this.submitBtn.title = this.t('listen.required'); this.submitBtn.textContent = this.t('listen.required'); return; } if (this.answered || !this.round) return; const r = this.round; const answer = r.mode === 'estimate' ? this.rangeAxis.value : { optionId: this.selectedOption }; if (r.mode !== 'estimate' && !answer.optionId) return; this.answered = true; this.submitBtn.disabled = true; const payload = await this.game.submit(answer); this.#renderFeedback(payload); } /** * Ödül sesi — uyaran DURDUKTAN sonra. * * ÜÇ KURAL, üçü de bilerek: * 1. Uyaranın üstüne ASLA binmez. `SILENCE_MS` bekler. * 2. Yanlışta HİÇBİR ŞEY çalmaz. Sessizlik dürüst sinyaldir; * utandırma sesi ölçüm aracına yakışmaz. * 3. Kullanıcı bir sonraki round'a geçtiyse bekleyen tık iptal edilir, * yoksa taze round'un uyaranına sızar. */ async #playReward(correct, near) { if (!correct && !near) return; // yanlış → sessizlik const token = ++this.#rewardToken; this.player.stop(); // uyaran gerçekten sussun await new Promise((r) => setTimeout(r, SILENCE_MS)); if (token !== this.#rewardToken) return; // round değişti, iptal if (correct) this.reward.correct(); else this.reward.near(); } #rewardToken = 0; /** Ses açık/kapalı düğmesi. Tercih localStorage'da kalır. */ #renderSoundToggle() { const btn = this.soundBtn; if (!btn) return; const paint = () => { const on = this.reward.enabled; btn.classList.toggle('is-off', !on); btn.textContent = on ? '\u266A' : '\u266A\u0338'; btn.title = this.t('sound.title'); btn.setAttribute('aria-pressed', String(on)); }; btn.addEventListener('click', () => { const on = this.reward.toggle(); paint(); if (on) this.reward.preview(); // açarken bir kez duyulsun }); paint(); } /* ═══════════ feedback ═══════════ */ #renderFeedback({ result, round, difficulty, progress, stage, unlockedStage, explain, stats }) { // şıkları işaretle if (round.mode !== 'estimate') { this.answerHost.querySelectorAll('.answers__opt').forEach((b) => { b.disabled = true; b.classList.remove('is-on'); if (b.dataset.opt === round.correctOptionId) b.classList.add('is-correct'); else if (b.dataset.opt === this.selectedOption) b.classList.add('is-wrong'); }); } else { this.rangeAxis.setDisabled(true); this.rangeAxis.revealTarget(round.targetUnit, result.correct); } this.detailEl.innerHTML = ''; this.detailEl.hidden = false; /* "Yakın" = yanlış ama az farkla. Ceza tonu yerine not tonu; ayrıca daha yumuşak, daha koyu bir tık çalıyor. Eşik `estimate` modunda aralık genişliğinin yarısı: doğru cevabın izin verilen genişliğinin yarısı kadar ıskalamak "az farkla"dır. Şık modunda yakınlık diye bir şey YOK — bir şıkkı ya seçtin ya seçmedin, arası olmaz. */ const half = (round.maxWidth ?? round.maxRangeWidth ?? 0) / 2; const near = !result.correct && round.mode === 'estimate' && result.missDistance > 0 && result.missDistance <= Math.max(0.02, half); const verdict = result.correct ? this.t('verdict.correct') : (near ? this.t('verdict.near') : this.t('verdict.wrong')); this.#playReward(result.correct, near); /* Hüküm ve açıklama YERLEŞİMİN İÇİNDE, tek satır. Açıklama suskun renkte: baştan sona kırmızı bir paragraf ceza tonudur, oysa yanlışta öğretici ton kullanılır (CLAUDE.md §7). */ const state = result.correct ? 'correct' : near ? 'near' : 'wrong'; /* "NEDEN KAÇIRDIN" — aşama başına TEK cümle yetmiyordu: aynı aşamada apayrı hatalar (yönü ters duymak ile hiç fark duymamak) aynı dersi alıyordu. `explainMiss` hatanın ŞEKLİNE bakıyor ve gerekirse aşamanın kendi cümlesine düşüyor. Ayrıca curriculum'un `explain`i TÜRKÇE veri; çeviri anahtarı varsa o kazanır. */ const why = explainMiss(round, result, { chosenId: this.selectedOption ?? null, targetValue: round?.target?.value, }); const whyText = why ? (this.t(why.key, why.params) === why.key ? explain : this.t(why.key, why.params)) : explain; this.revealEl.innerHTML = `${verdict}` + (whyText ? `` : ''); this.revealEl.hidden = false; this.nextBtn.hidden = false; this.submitBtn.hidden = true; // gerçek ayarlar const reveal = h('div', { class: 'feedback__reveal' }); for (const s of round.stimuli) { reveal.append(h('div', { class: 'reveal-row' }, h('b', { text: this.#label(s, 'revealLabelKey', 'revealLabel') }), s.revealDetail ? h('span', { text: s.revealDetail }) : null)); } this.detailEl.append(reveal); // gain reduction grafiği const colors = seriesColors(); this.grGraph.setSeries(round.stimuli.map((s, i) => ({ label: this.#label(s, 'revealLabelKey', 'revealLabel'), waveform: s.waveform, grEnvelope: s.grEnvelope, color: colors[i % colors.length], }))); this.detailEl.append(this.grGraph.el); this.grGraph.draw(); // etiketli tekrar dinleme const relisten = h('div', { class: 'transport transport--relisten' }); for (const s of round.stimuli) { relisten.append(this.#playButton(s, this.#label(s, 'revealLabelKey', 'revealLabel'))); } this.detailEl.append( h('div', { class: 'lesson__demo-label', text: this.t('feedback.relisten') }), relisten); // sayısal özet const grid = h('div', { class: 'feedback__grid' }); const cell = (label, value) => h('div', {}, h('span', { text: label }), h('b', { text: value })); grid.append(cell(this.t('label.difficulty'), difficulty.displayLevel.toFixed(1))); grid.append(cell(this.t('cell.evidence'), `${progress.evidence}/${progress.target}`)); if (round.mode === 'estimate') { grid.append(cell(this.t('cell.widthUsed'), `${Math.round(result.widthUsed * 100)}%`)); grid.append(cell(this.t('cell.score'), result.score.toFixed(2))); } else { grid.append(cell(this.t('cell.discriminability'), `${Math.round(round.discriminability * 100)}%`)); grid.append(cell(this.t('cell.material'), round.materialName)); } const numbers = h('details', { class: 'academy-feedback-details' }); numbers.append(h('summary', {text: this.t('label.difficulty') + ' · ' + this.t('cell.evidence')}), grid); this.detailEl.append(numbers); /* Aşama açıldıysa asıl olay budur: gösterim satırına eklenir ve alt panelde geçiş düğmesi çıkar. "Sıradaki round" artık yerleşimin alt satırında (`ui.next`), ayrı bir aksiyon yığını yok. */ if (unlockedStage) { this.revealEl.innerHTML += ` · ${this.t('unlock.body', { stage: unlockedStage.title })}`; this.detailEl.append(h('div', { class: 'actions' }, h('button', { class: 'btn', type: 'button', onclick: () => this.#openStage(unlockedStage.id), }, this.t('unlock.go')))); } // Odak düğmede kalır (Enter ile devam çalışır) ama sayfa KAYMAZ — kullanıcı // istedi: cevaptan sonra görüş bozulmasın, ayrıntılara kendisi insin. // Eskiden tarayıcı odaklanan düğmeyi göstermek için sayfayı aşağı atıyordu. this.nextBtn.focus({ preventScroll: true }); this.#renderBadges(); this.#showProgress(true, `${result.points >= 0 ? '+' : ''}${result.points} XP`); this.setDone += 1; renderSetStrip(this.setStrip, this.setDone); /* SON SORU (kullanıcı bildirdi: "setin son sorusunda doğru/yanlış geri bildirimi gelmiyor"). Eskiden burada özet ekranı HEMEN açılıyor ve sonuç çizildiği anda gizleniyordu. Artık sonuç görünür kalır, "sonraki" düğmesi "seti bitir →" olur ve özeti o açar (masking'in zaten kullandığı akış). */ if (this.setDone >= SET_ROUNDS) { this._setOver = { btn: this.nextBtn, label: this.nextBtn.textContent }; this.nextBtn.hidden = false; this.nextBtn.textContent = `${this.t('set.exit')} →`; this.nextBtn.focus({ preventScroll: true }); } } /* ═══════════ ölçerler ═══════════ */ /** * Aşama ve XP — round sırasında GİZLİ, cevaptan sonra tek satır. * * Bilgi silinmiyor; sitenin ilerleme ekranında henüz karşılığı yok. * Ölçer ve çubuk kalktı: bunlar oyun ekranında okunmuyordu. */ #renderStageLine(extra = '') { const stage = this.game.stage; const st = this.game.state.stages[stage.id]; /* KANIT + NEDEN. Sayaç neden durduğu yazılmazsa bozuk sanılır (docs/ilerleme-kanit.md §6). */ const L = this.game.passLevelFor(stage.id); const lvl = this.game.difficulty.get(stage.id).displayLevel; const parts = [`${stage.index}. ${this.#stageText(stage.id, 'title', stage.title)}`]; if (L === null) parts.push(`${st.rounds} round`); else { parts.push(this.t('stage.evidence', { n: st.evidence, of: 12 })); parts.push(lvl >= L - 1e-9 ? this.t('stage.atLevel', { lvl: L.toFixed(1), now: lvl.toFixed(1) }) : this.t('stage.belowLevel', { now: lvl.toFixed(1) })); } if (extra) parts.push(extra); this.stageLine.textContent = parts.filter(Boolean).join(' · '); } /** Sağ üstte yalnızca malzeme adı. */ #renderMaterial() { this.materialLine.textContent = this.round?.materialName ?? ''; } #renderBadges() { const stage = this.game.stage; const st = this.game.state.stages[stage.id]; this.badges.innerHTML = ''; this.badges.append(h('span', { class: 'badge badge--accent', text: `${stage.index}. ${this.#stageText(stage.id, 'title', stage.title)}` })); this.badges.append(h('span', { class: 'badge', text: stage.phase })); if (st.completed) this.badges.append(h('span', { class: 'badge badge--ok', text: this.t('badge.completed') })); } /** * Asama rozetleri ve XP seridi uyaran calarken GIZLI. * Ekranda yalnizca soru, ses ve cevap kalir; bilgi silinmiyor, cevap * verilir verilmez geri geliyor. */ #showProgress(on, extra = '') { if (on) this.#renderStageLine(extra); this.stageLine.hidden = !on; } /* ═══════════ olaylar ═══════════ */ #bindGame() { this.player.on('play', (id) => this.#markPlaying(id)); this.player.on('stop', () => this.#markPlaying(null)); this.game.on('state:changed', () => { if (this.screen === 'map') this.#renderStageMap(); }); } #markPlaying(id) { if (this.screen === 'play' && this.round === this.game.current) this.game.markListened(id); // Çalan yarı İÇERİDEN çerçevelenir: dış çerçeve kutuyu büyütüp // yerleşimi oynatıyordu, uyaran çalarken ekran kıpırdamamalı. this.root.querySelectorAll('[data-stim]').forEach((b) => { b.classList.toggle('is-on', b.dataset.stim === id); }); } /* ═══════════ set ═══════════ */ #resetSet() { this.setDone = 0; // Set sonunda KANIT hareketi gösterilir: `kanıt 6 → 8`. this.setStartLevel = this.game.state.stages[this.game.stage.id]?.evidence ?? 0; this.setStartStage = this.game.stage.index; renderSetStrip(this.setStrip, 0); } /** * Set sonu — bir durak, bir hedef değil. * * Gösterilen: aşama hareketi, kaç round oynandığı, aşama/XP durumu. * Gösterilmeyen: doğruluk oranı (motor onu %80'de sabitler, her sette * aynı çıkar — CLAUDE.md §2). */ #endSet() { if (this._setOver) { this._setOver.btn.textContent = this._setOver.label; this._setOver = null; } academyShowSetTask(this.setEndScreen, practiceFor(this.t.locale, this.game.stage.id)?.task); // kendi miksinde dene const stage = this.game.stage; const st = this.game.state.stages[stage.id]; this.setLevelFrom.textContent = String((this.setStartStage ?? stage.index)); this.setLevelTo.textContent = String(stage.index); this.setRoundsEl.textContent = this.t('set.rounds', { n: this.setDone }); /* Set sonunda KANIT HAREKETİ: `kanıt 6 → 8`. Sayaç geriye de gidebilir ve bu kasıtlı — anlatılmazsa hata okunur. */ const L = this.game.passLevelFor(stage.id); this.setStageEl.textContent = `${stage.index}. ${this.#stageText(stage.id, 'title', stage.title)} · ` + (L === null ? this.t('set.rounds', { n: st.rounds }) : this.t('stage.evidenceMove', { from: this.setStartLevel ?? st.evidence, to: st.evidence, })); this.setEndScreen.classList.toggle('is-up', stage.index > (this.setStartStage ?? stage.index)); this.#show('setend'); } #bindKeys() { window.addEventListener('keydown', (e) => { if (e.target.matches('input, select, textarea')) return; if (this.screen === 'play' && this.round) { const idx = ['1', '2', '3', '4'].indexOf(e.key); if (idx >= 0 && this.round.transport[idx]) { e.preventDefault(); this.player.toggle(this.round.byId[this.round.transport[idx]]); return; } if (e.code === 'Space') { e.preventDefault(); const id = this.player.currentId ?? this.round.transport[0]; this.player.toggle(this.round.byId[id]); return; } if (e.key === 'Enter') { e.preventDefault(); if (this.answered) this.#nextRound(); else if (!this.submitBtn.disabled) void this.#submit(); } } else if (this.screen === 'lesson' && this.demo) { const idx = ['1', '2', '3', '4'].indexOf(e.key); if (idx >= 0 && this.demo.stimuli[idx]) { e.preventDefault(); this.player.toggle(this.demo.stimuli[idx]); } } }); } } /* ── src\main.js ────────────────────── */ /** * main.js — composition root. * * Siteye/hesap sistemine geçişte değişecek TEK satır aşağıdaki store * seçimidir (platform şartnamesi §5): * * const store = new ApiProgressStore({ baseUrl: '/api/eartraining', ... }); * * Oyun kodunun hiçbir yeri değişmez. */ async function boot(root = document.getElementById('app')) { const store = new LocalStorageProgressStore(); const engine = new AudioEngine({ masterGainDb: AUDIO.masterGainDb }); const materials = new MaterialLibrary(); // Render örnekleme hızı, context açılana kadar bilinmez; 44.1k varsayılır // ve unlock sonrası gerçek değere ayarlanır. const game = new CompressionGame({ store, materials, sampleRate: 44100 }); const player = new AudioPlayer(engine); await game.load(); const view = new GameView({ root, game, player, engine }); view.restoreTheme(); // Context açıldıktan sonra gerçek örnekleme hızını kullan const originalUnlock = engine.unlock.bind(engine); engine.unlock = async () => { const state = await originalUnlock(); if (engine.sampleRate && game.factory.sampleRate !== engine.sampleRate) { game.factory.sampleRate = engine.sampleRate; } return state; }; /** * PLATFORMDAN GELEN AŞAMA. * * Site aşama haritasından seçilen aşama `window.__ET_STAGE__` olarak * gelir (bkz. src/gamePatch.mjs). Oyun tek başına çalışırken yoktur. * * ZORLUK DÜŞMEZ: `selectStage` yalnızca hangi aşamanın sorulduğunu * değiştirir, zorluk motoruna dokunmaz. Kilitli aşama istenirse oyun * hata fırlatır; burada yutulur ve oyun kaldığı yerden başlar. */ const wantedStage = typeof window !== 'undefined' ? window.__ET_STAGE__ : null; if (wantedStage && game.state.unlocked.includes(wantedStage)) { game.selectStage(wantedStage); } // Platform içindeyken `startScreen`i (ses kilidi + ses seviyesi) atlar, // doğrudan seçili aşamaya (Dinleme Odası'na) girer — bkz. GameView#start // için gerekçe. `selectStage`den SONRA çağrılmalı: önce çağrılsaydı // eski aşamayı açardı. view.start(); Object.assign(globalThis, { __compression: { game, engine, player, view, store } }); return { game, engine, player, view }; } if (typeof document !== 'undefined' && document.getElementById('app')) { boot().catch((err) => { console.error(err); const el = document.getElementById('app'); if (el) el.innerHTML = `
Başlatma hatası: ${err.message}
`; }); }