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 ? `${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}`;
});
}