Files

1422 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Numex Candy — Oyun arayüzü denetleyicisi (main controller)
* --------------------------------------------------------------------------
* Sunucudan bölüm verisini çeker, motor modüllerini kullanarak oyunu
* yürütür ve DOM'u günceller.
*
* Akış:
* fetch /api/levels/:id → seviye + tahta düzeni
* buildBoard() → DOM ızgarasını kur
* onCellClick / drag → takas dene → motor → animasyon → skor
* checkLevelEnd() → hedefe göre bölümü bitir
*
* Numex AI Bilişim Teknolojileri tarafından geliştirildi.
*/
import {
tahtaOlustur,
tahtaKopyala,
tahtayiDoldur,
takasEt,
eslesmeVarMi,
hamleVarMi,
sekerVar,
TIP_SAYISI,
} from '/engine/board.js';
import { eslesmeleriBul, patlamaYayilimi } from '/engine/match.js';
import { zincirlemeCalistir, yerCekimiUygula } from '/engine/cascade.js';
import {
puanHesapla,
yildizHesapla,
sonrakiEsik,
boosterPaketi,
hedefDurumu,
bolumDegerlendir,
} from '/engine/score.js';
// ==========================================================================
// DURUM
// ==========================================================================
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const S = {
/** Motor tahtası — { tip, ozel?, yeni? } | 'block' | null */
tahta: [],
/** Bölüm meta bilgisi */
level: null,
/** Kırılacak jel hücreleri ("r:c") — Set */
jel: new Set(),
/** Toplanan şeker sayacı — { tipId: adet } */
sayac: {},
/** Anlık puan */
skor: 0,
/** Kalan hamle */
hamle: 0,
/** Kalan güçlendiriciler */
booster: { cekir: 0, karistir: 0, bomba: 0 },
/** Seçili güçlendirici */
aktifBooster: null,
/** Seçili hücre [r, c] */
secili: null,
/** Girdi kilidi — animasyon sürerken hamle yapılamaz */
kilitli: false,
/** Bölüm bitti mi */
bitti: false,
/** Ses açık mı */
ses: true,
/** İlerleme kaydı */
kayit: { acikBolum: 1, yildizlar: {}, enIyi: {} },
/** Kombo sayacı — zincir göstergesi */
kombo: 0,
/** Aktif sürükleme oturumu — { aktif, r, c, kilit, baslaX, baslaY, el } */
surukle: null,
};
const DEPO_ANAHTAR = 'numex-candy-kayit-v1';
// ==========================================================================
// KAYIT / İLERLEME
// ==========================================================================
function kayitYukle() {
try {
const ham = localStorage.getItem(DEPO_ANAHTAR);
if (ham) {
const veri = JSON.parse(ham);
S.kayit = {
acikBolum: veri.acikBolum || 1,
yildizlar: veri.yildizlar || {},
enIyi: veri.enIyi || {},
};
}
} catch (e) {
// bozuk kayıt — sıfırdan başla, oyunu kilitleme
S.kayit = { acikBolum: 1, yildizlar: {}, enIyi: {} };
}
}
function kayitSakla() {
try {
localStorage.setItem(DEPO_ANAHTAR, JSON.stringify(S.kayit));
} catch (e) {
/* localStorage kapalı olabilir — sessizce devam */
}
}
function toplamYildiz() {
return Object.values(S.kayit.yildizlar).reduce((a, b) => a + b, 0);
}
function enIyiPuan() {
const degerler = Object.values(S.kayit.enIyi);
return degerler.length ? Math.max(...degerler) : 0;
}
// ==========================================================================
// SES (WebAudio ile sentez — harici dosya yok)
// ==========================================================================
let sesCtx = null;
function sesCal(tur) {
if (!S.ses) return;
try {
if (!sesCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
sesCtx = new AC();
}
if (sesCtx.state === 'suspended') sesCtx.resume();
const now = sesCtx.currentTime;
const osc = sesCtx.createOscillator();
const gain = sesCtx.createGain();
const ayarlar = {
sec: { f: 480, t: 'sine', d: 0.06, v: 0.07 },
takas: { f: 620, t: 'triangle', d: 0.08, v: 0.06 },
patlama: { f: 320, t: 'square', d: 0.09, v: 0.05 },
kombo: { f: 880, t: 'sine', d: 0.16, v: 0.08 },
kazan: { f: 740, t: 'sine', d: 0.4, v: 0.1 },
kaybet: { f: 180, t: 'sawtooth', d: 0.4, v: 0.08 },
booster: { f: 1040, t: 'triangle', d: 0.12, v: 0.08 },
}[tur] || { f: 500, t: 'sine', d: 0.06, v: 0.05 };
osc.type = ayarlar.t;
osc.frequency.setValueAtTime(ayarlar.f, now);
if (tur === 'kombo') osc.frequency.exponentialRampToValueAtTime(ayarlar.f * 1.9, now + ayarlar.d);
if (tur === 'kaybet') osc.frequency.exponentialRampToValueAtTime(90, now + ayarlar.d);
gain.gain.setValueAtTime(ayarlar.v, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + ayarlar.d);
osc.connect(gain);
gain.connect(sesCtx.destination);
osc.start(now);
osc.stop(now + ayarlar.d + 0.02);
} catch (e) {
/* ses başarısız olsa da oyun devam eder */
}
}
// ==========================================================================
// API
// ==========================================================================
async function bolumGetir(id) {
const yanit = await fetch(`/api/levels/${id}`);
if (!yanit.ok) throw new Error('Bölüm yüklenemedi: ' + yanit.status);
return yanit.json();
}
async function skorGonder(levelId, isim, skor, yildiz) {
try {
await fetch('/api/leaderboard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ levelId, isim, skor, yildiz }),
});
} catch (e) {
/* çevrimdışı ise skor gönderilemez — oyunu etkilemesin */
}
}
async function skorTablosuGetir(levelId) {
try {
const yanit = await fetch(`/api/leaderboard/${levelId}`);
if (!yanit.ok) return [];
const veri = await yanit.json();
return veri.kayitlar || [];
} catch (e) {
return [];
}
}
// ==========================================================================
// TAHTA ÇİZİMİ
// ==========================================================================
/** Şeker tipine göre CSS sınıfı üretir. */
function sekerSinifi(tip, ozel) {
const sekiller = ['kare', 'daire', 'baklava', 'kare', 'daire', 'baklava'];
const sekil = sekiller[tip % sekiller.length];
let sinif = `candy c-t${tip} s-${sekil}`;
if (ozel) sinif += ` ozel-${ozel}`;
return sinif;
}
/** Tahta boyutuna göre ızgara sütunlarını ayarlar. */
function izgaraAyarla(sutun) {
const board = $('#board');
board.style.gridTemplateColumns = `repeat(${sutun}, minmax(0, 1fr))`;
board.style.gridTemplateRows = `repeat(${S.tahta.length}, minmax(0, 1fr))`;
}
/**
* Tek bir hücrenin DOM içeriğini tahtadaki duruma göre günceller.
*
* AKICI SÜRÜM: Şeker elementi artık her çizimde yok edilip yeniden
* yaratılmaz. Aynı tip/özel durum korunuyorsa mevcut element yerinde
* bırakılır — böylece CSS geçişleri (patlama, düşme, jel) kesintisiz akar.
* Candy Crush'ın "yerleşik şeker" hissi buradan gelir.
*/
function hucreGuncelle(r, c) {
const hucre = document.querySelector(`.cell[data-r="${r}"][data-c="${c}"]`);
if (!hucre) return;
const deger = S.tahta[r][c];
const jelVar = S.jel.has(`${r}:${c}`);
const sekerMi = sekerVar(S.tahta, r, c);
hucre.classList.toggle('jelly', jelVar);
hucre.classList.toggle('playable', deger !== 'block');
hucre.classList.toggle('block', deger === 'block');
const mevcut = hucre.querySelector('.candy');
// --- hiç şeker yoksa ---
if (!sekerMi) {
if (mevcut) mevcut.remove();
hucre.classList.remove('dusuyor', 'selected');
return;
}
const yeniSinif = sekerSinifi(deger.tip, deger.ozel);
// --- aynı tipte şeker duruyorsa: sadece sınıfı tazele, elementi koru ---
if (mevcut && mevcut.className === yeniSinif) {
delete deger.yeni;
} else {
// --- farklı şeker: eskiyi (varsa) değiştir ---
if (mevcut) mevcut.remove();
const el = document.createElement('span');
el.className = yeniSinif;
el.dataset.tip = String(deger.tip);
hucre.appendChild(el);
delete deger.yeni;
}
// --- seçim vurgusu ---
hucre.classList.toggle(
'selected',
!!S.secili && S.secili[0] === r && S.secili[1] === c
);
}
/**
* Tüm tahtayı çizer.
*
* AKICI SÜRÜM: elementleri tek tek gezmek yerine yalnızca gerçekten
* değişen hücrelere dokunur. 8x8 tahtada her turda 64 DOM düğümü
* yok edilip yeniden yaratılmaz — oyun "zıplamaz", akar.
*/
function tahtayiCiz() {
for (let r = 0; r < S.tahta.length; r++) {
for (let c = 0; c < S.tahta[0].length; c++) {
hucreGuncelle(r, c);
}
}
}
/**
* Yerçekimi sonrası düşme animasyonunu oynatır.
*
* FLIP yaklaşımı: önce her hücrenin şekerine "nereye düştüğü" bilgisi
* verilir (satır farkına göre translateY başlangıcı), bir sonraki karede
* sıfıra çekilir. Böylece şeker yukarıdan akarak yerine oturur ve düşme
* mesafesi arttıkça animasyon da uzar — gerçek yerçekimi hissi.
*/
function dusmeAnimasyonu(oncekiSatirlar) {
const board = $('#board');
if (!board) return;
const hareketli = [];
for (let r = 0; r < S.tahta.length; r++) {
for (let c = 0; c < S.tahta[0].length; c++) {
const oncekiR = oncekiSatirlar[r]?.[c];
if (oncekiR === undefined || oncekiR === r) continue;
const hucre = board.querySelector(`.cell[data-r="${r}"][data-c="${c}"]`);
const seker = hucre && hucre.querySelector('.candy');
if (!seker) continue;
hareketli.push({ seker, mesafe: r - oncekiR });
}
}
if (hareketli.length === 0) return;
// 1) başlangıç konumları (animasyonsuz)
for (const { seker, mesafe } of hareketli) {
seker.style.transition = 'none';
seker.style.transform = `translateY(${-mesafe * 100}%)`;
seker.style.opacity = mesafe > 1 ? '0' : '0.75';
}
// 2) bir sonraki karede hedefe süzülme (mesafeye göre süre)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const { seker, mesafe } of hareketli) {
const sure = Math.min(420, 120 + mesafe * 70);
seker.style.transition = `transform ${sure}ms cubic-bezier(.34,.02,.62,.99), opacity ${sure * 0.6}ms ease`;
seker.style.transform = 'translateY(0)';
seker.style.opacity = '1';
}
});
});
return Math.min(430, 130 + Math.max(...hareketli.map((h) => h.mesafe)) * 70);
}
/**
* Tahta DOM ızgarasını kurar ve olay dinleyicilerini bağlar.
* Hem fare (sürükle) hem dokunma desteklenir.
*/
function tahtaKur() {
const board = $('#board');
S.surukle = null; // tahta yeniden kurulurken yarım kalan sürükleme sıfırlanır
board.innerHTML = '';
izgaraAyarla(S.tahta[0].length);
for (let r = 0; r < S.tahta.length; r++) {
for (let c = 0; c < S.tahta[0].length; c++) {
const hucre = document.createElement('div');
hucre.className = 'cell';
hucre.dataset.r = r;
hucre.dataset.c = c;
// NOT: Girdi olayları tahta (board) üzerinde TEK merkezi dinleyiciyle
// yönetilir — aşağıdaki `girdiyiBagla()`. Böylece parmak bir hücreden
// diğerine kaydığında olay kaybolmaz (pointer capture).
board.appendChild(hucre);
}
}
girdiyiBagla();
tahtayiCiz();
}
// ==========================================================================
// GİRDİ MOTORU — dokunma + fare uyumlu sürükle-bırak
// ==========================================================================
/** Sürüklemenin başlaması için gereken en küçük parmak/fare mesafesi (px). */
const SURUKLEME_ESIGI = 6;
/** Koordinattan hücrenin [r, c] konumunu bulur (tahta içinde değilse null). */
function hucreKonumuBul(x, y) {
const hucre = document.elementFromPoint(x, y);
const hedef = hucre && hucre.classList.contains('cell') ? hucre : hucre && hucre.closest('.cell');
if (!hedef) return null;
const r = Number(hedef.dataset.r);
const c = Number(hedef.dataset.c);
if (!Number.isInteger(r) || !Number.isInteger(c)) return null;
return [r, c];
}
/**
* Tahtanın tüm işaretçi girdilerini tek noktadan yönetir.
*
* Klasik "pointerup'ı hücreye bağla" yaklaşımı mobilde bozulur: parmak
* başka hücreye kayınca yukarı kaldırma olayı başlangıç hücresine
* ulaşmaz. Bu yüzden sürükleme, pointer capture ile tahta üzerinde
* baştan sona takip edilir ve yön ANINDA (bırakmadan) belirlenir.
*/
function girdiyiBagla() {
const board = $('#board');
// ---- eşzamanlı çoklu dokunuşu engelle (tek aktif sürükleme) ----
board.addEventListener('pointerdown', (ev) => {
if (S.kilitli || S.bitti) return;
if (S.surukle) return; // ikinci parmak yok sayılır
if (ev.button !== undefined && ev.button !== 0 && ev.pointerType === 'mouse') return;
const konum = hucreKonumuBul(ev.clientX, ev.clientY);
if (!konum) return;
const [r, c] = konum;
if (S.tahta[r][c] === 'block') return;
// ---- güçlendirici modu: sürükleme yok, tıklama yeter ----
if (S.aktifBooster) {
hucreTikla(r, c);
return;
}
if (!sekerVar(S.tahta, r, c)) return;
ev.preventDefault();
try {
board.setPointerCapture(ev.pointerId);
} catch {
/* capture desteklenmiyorsa pointerup yine de board'a düşer */
}
S.surukle = {
r,
c,
kilit: null, // [nr, nc] — yön bir kez belirlenir
baslaX: ev.clientX,
baslaY: ev.clientY,
pointerId: ev.pointerId,
};
// NOT: Seçim vurgusu burada YAPILMAZ. Seçimi yalnızca hucreTikla()
// yönetir; aksi halde pointerdown'da set edilen seçim, hemen ardından
// gelen pointerup → hucreTikla() tarafından "aynı hücreye tekrar
// tıklandı" sanılıp iptal ediliyor ve TAKAS HİÇ GERÇEKLEŞMİYORDU
// (hamle düşmüyor, eşleşme/puan oluşmuyordu). Bu yüzden burada
// yalnızca sürükleme durumu kurulur.
});
// ---- parmak/fare hareketi: yön belirle ve canlı önizleme ----
board.addEventListener('pointermove', (ev) => {
const sur = S.surukle;
if (!sur || sur.pointerId !== ev.pointerId) return;
// zaten kilitlendiyse ekstra iş yapma
if (sur.kilit) return;
const dx = ev.clientX - sur.baslaX;
const dy = ev.clientY - sur.baslaY;
if (Math.hypot(dx, dy) < SURUKLEME_ESIGI) return; // titreme — henüz sürükleme değil
// baskın eksene göre komşu hücreyi seç
let nr = sur.r;
let nc = sur.c;
if (Math.abs(dx) > Math.abs(dy)) nc += dx > 0 ? 1 : -1;
else nr += dy > 0 ? 1 : -1;
// hedef geçerli mi? (tahta dışı / blok / boş → sürüklemeyi iptal et)
if (
nr < 0 ||
nc < 0 ||
nr >= S.tahta.length ||
nc >= S.tahta[0].length ||
S.tahta[nr][nc] === 'block' ||
!sekerVar(S.tahta, nr, nc)
) {
return;
}
sur.kilit = [nr, nc];
onizlemeGuncelle(sur.r, sur.c, nr, nc);
});
// ---- bırakma: hamleyi uygula ----
const birak = async (ev) => {
const sur = S.surukle;
if (!sur || sur.pointerId !== ev.pointerId) return;
S.surukle = null;
try {
board.releasePointerCapture(ev.pointerId);
} catch {
/* yoksay */
}
onizlemeTemizle();
const dx = ev.clientX - sur.baslaX;
const dy = ev.clientY - sur.baslaY;
// hiç sürüklenmediyse → normal tıklama akışı
if (!sur.kilit && Math.hypot(dx, dy) < SURUKLEME_ESIGI) {
await hucreTikla(sur.r, sur.c);
return;
}
// bırakma anında yön henüz kilitlenmemişse burada çöz
let hedef = sur.kilit;
if (!hedef) {
let nr = sur.r;
let nc = sur.c;
if (Math.abs(dx) > Math.abs(dy)) nc += dx > 0 ? 1 : -1;
else nr += dy > 0 ? 1 : -1;
hedef = [nr, nc];
}
const [nr, nc] = hedef;
if (
nr < 0 ||
nc < 0 ||
nr >= S.tahta.length ||
nc >= S.tahta[0].length ||
S.tahta[nr][nc] === 'block' ||
!sekerVar(S.tahta, nr, nc)
) {
S.secili = null;
tahtayiCiz();
return;
}
S.secili = null;
await takasDene(sur.r, sur.c, nr, nc);
};
board.addEventListener('pointerup', birak);
board.addEventListener('pointercancel', (ev) => {
if (!S.surukle || S.surukle.pointerId !== ev.pointerId) return;
S.surukle = null;
onizlemeTemizle();
S.secili = null;
tahtayiCiz();
});
// ---- tarayıcı varsayılan sürükleme / kaydırmayı kapat ----
board.addEventListener('dragstart', (ev) => ev.preventDefault());
board.addEventListener('contextmenu', (ev) => ev.preventDefault());
}
/** Sürükleme sırasında kaynak/hedef hücreyi vurgular. */
function onizlemeGuncelle(r, c, nr, nc) {
onizlemeTemizle();
const kaynak = document.querySelector(`.cell[data-r="${r}"][data-c="${c}"]`);
const hedef = document.querySelector(`.cell[data-r="${nr}"][data-c="${nc}"]`);
if (kaynak) kaynak.classList.add('surukleniyor');
if (hedef) hedef.classList.add('hedef');
}
/** Sürükleme önizleme vurgularını kaldırır. */
function onizlemeTemizle() {
$$('.cell.surukleniyor, .cell.hedef').forEach((el) => {
el.classList.remove('surukleniyor', 'hedef');
});
}
// ==========================================================================
// HUD / PANEL ÇİZİMİ
// ==========================================================================
function hudGuncelle() {
$('#hudLevel').textContent = S.level.id;
$('#hudScore').textContent = S.skor.toLocaleString('tr-TR');
$('#hudMoves').textContent = S.hamle;
$('.hud-moves')?.classList.toggle('danger', S.hamle <= 3);
}
function hedefleriCiz() {
const kap = $('#goals');
kap.innerHTML = '';
const jelSayisi = S.jel.size;
const durumlar = hedefDurumu(S.level, S.skor, S.sayac, jelSayisi);
for (const d of durumlar) {
const el = document.createElement('div');
el.className = 'goal' + (d.tamam ? ' done' : '');
let chipHtml = '';
if (d.tip === 'collect' && d.renk !== undefined) {
const sekiller = ['kare', 'daire', 'baklava', 'kare', 'daire', 'baklava'];
chipHtml = `<span class="goal-chip ${sekiller[d.renk % 6]}" style="background:var(--t${d.renk})"></span>`;
}
el.innerHTML = `
<div class="goal-top">
${chipHtml}
<span class="goal-text">${d.etiket}</span>
<span>${d.tamam ? '✓' : ''}</span>
</div>
<div class="goal-bar"><i style="width:${Math.round((d.ilerleme || 0) * 100)}%"></i></div>
`;
kap.appendChild(el);
}
}
function boosterleriCiz() {
const kap = $('#boosters');
kap.innerHTML = '';
const tanimlar = [
{ id: 'cekir', ikon: '🔨', ad: 'Çekiç' },
{ id: 'karistir', ikon: '🔀', ad: 'Karıştır' },
{ id: 'bomba', ikon: '💥', ad: 'Bomba' },
];
for (const t of tanimlar) {
const btn = document.createElement('button');
btn.className = 'booster' + (S.aktifBooster === t.id ? ' active' : '');
btn.disabled = S.booster[t.id] <= 0 || S.bitti;
btn.innerHTML = `
<span class="booster-icon">${t.ikon}</span>
<span class="booster-name">${t.ad}</span>
<span class="booster-count">${S.booster[t.id]}</span>
`;
btn.addEventListener('click', () => boosterSec(t.id));
kap.appendChild(btn);
}
}
function yildizCubuguGuncelle() {
const yildizlar = S.level.yildizlar;
const kazanilan = yildizHesapla(S.skor, yildizlar);
$$('#starsTrack .star').forEach((el, i) => {
el.classList.toggle('on', i < kazanilan);
});
// ilerleme çubuğu: 0 → 1. yıldız arası, sonra sonraki eşiklere göre
const oncekiEsik = kazanilan === 0 ? 0 : yildizlar[kazanilan - 1];
const sonrakiEsik = kazanilan >= 3 ? null : yildizlar[kazanilan];
let oran = 1;
if (sonrakiEsik !== null) {
oran = (S.skor - oncekiEsik) / (sonrakiEsik - oncekiEsik);
}
$('#starsBar').style.width = Math.max(0, Math.min(1, oran)) * 100 + '%';
const sonraki = sonrakiEsik;
$('#starsNext').textContent =
sonraki === null ? '3 yıldız! Maksimum.' : `Sonraki yıldız: ${sonraki.toLocaleString('tr-TR')}`;
}
function komboGoster(metin) {
const el = $('#comboBanner');
el.textContent = metin;
el.hidden = false;
el.style.animation = 'none';
void el.offsetWidth; // reflow — animasyonu yeniden başlat
el.style.animation = '';
setTimeout(() => {
el.hidden = true;
}, 900);
}
function puanUcusu(r, c, puan) {
const hucre = document.querySelector(`.cell[data-r="${r}"][data-c="${c}"]`);
if (!hucre) return;
const kutu = hucre.getBoundingClientRect();
const el = document.createElement('div');
el.className = 'puan-ucusu';
el.textContent = '+' + puan;
el.style.left = kutu.left + kutu.width / 2 - 14 + 'px';
el.style.top = kutu.top + 'px';
el.style.position = 'fixed';
document.body.appendChild(el);
setTimeout(() => el.remove(), 820);
}
// ==========================================================================
// OYUN MANTIĞI — HAMLE
// ==========================================================================
/**
* Hücre tıklamasını işler.
* İki mod var:
* 1) Güçlendirici aktifse → hedefe uygula
* 2) Aksi halde → şeker seç / komşuyla takas et
*/
async function hucreTikla(r, c, zorlananKomsu = null) {
if (S.kilitli || S.bitti) return;
if (S.tahta[r][c] === 'block') return;
// ---- güçlendirici modu ----
if (S.aktifBooster) {
await boosterUygula(S.aktifBooster, r, c);
return;
}
if (!sekerVar(S.tahta, r, c)) return;
// ---- sürükleme ile zorlanan komşu ----
if (zorlananKomsu) {
const [nr, nc] = zorlananKomsu;
await takasDene(r, c, nr, nc);
return;
}
// ---- tıklama akışı ----
if (!S.secili) {
S.secili = [r, c];
sesCal('sec');
tahtayiCiz();
return;
}
const [sr, sc] = S.secili;
// aynı hücreye tekrar tıklandı → seçimi kaldır
if (sr === r && sc === c) {
S.secili = null;
tahtayiCiz();
return;
}
// komşu mu?
const komsu = Math.abs(sr - r) + Math.abs(sc - c) === 1;
if (!komsu) {
S.secili = [r, c];
sesCal('sec');
tahtayiCiz();
return;
}
S.secili = null;
await takasDene(sr, sc, r, c);
}
/**
* İki komşu hücreyi takas etmeyi dener.
* Eşleşme oluşmazsa takası geri alır (ceza yok — hamle harcanmaz).
*/
async function takasDene(r1, c1, r2, c2) {
if (S.kilitli || S.bitti) return;
const h1 = S.tahta[r1][c1];
const h2 = S.tahta[r2][c2];
if (h1 === 'block' || h2 === 'block' || !h1 || !h2) return;
S.kilitli = true;
// takası uygula
takasEt(S.tahta, r1, c1, r2, c2);
tahtayiCiz();
// takasAnimasyonu kendi süresini bekler — ekstra bekle() ÇAĞIRMA (çakışma/takılma önlenir).
await takasAnimasyonu(r1, c1, r2, c2);
sesCal('takas');
if (!eslesmeVarMi(S.tahta)) {
// geçersiz hamle — şekerler yerine süzülerek geri döner (kısa nefes payı)
await bekle(20);
takasEt(S.tahta, r1, c1, r2, c2);
tahtayiCiz();
await takasAnimasyonu(r1, c1, r2, c2);
sesCal('gecersiz');
S.kilitli = false;
return;
}
// geçerli hamle: hamle harca
S.hamle--;
hudGuncelle();
await patlamaDongusu();
// tahta tıkandıysa karıştır
if (!hamleVarMi(S.tahta)) {
await tahtayiKaristir();
}
S.kilitli = false;
await bolumSonuKontrol();
}
/** Takas animasyonunun süresi (ms) — akıcı ama bekletmeyen tempo. */
const TAKAS_MS = 165;
/**
* İki komşu hücrenin şekerlerini birbirine doğru KAYDIRARAK gösterir.
*
* Neden gerekli: `tahtayiCiz()` tahtayı anında günceller, ama kullanıcı
* takası "kayarak yer değiştirme" olarak görmelidir. Burada iki şeker
* ters yöne translate edilir, sonra sıfırlanır — böylece akış kesintisiz
* ve "elle tutulur" hissi verir (Candy Crush takas hissi).
*/
async function takasAnimasyonu(r1, c1, r2, c2) {
const board = $('#board');
if (!board) return;
const h1 = board.querySelector(`.cell[data-r="${r1}"][data-c="${c1}"] .candy`);
const h2 = board.querySelector(`.cell[data-r="${r2}"][data-c="${c2}"] .candy`);
if (!h1 && !h2) return;
const dikey = r1 !== r2;
const yon = dikey ? 1 : 0;
const adim = dikey ? (r2 - r1) : (c2 - c1);
const eksen = dikey ? 'Y' : 'X';
// başlangıç: geriye doğru (henüz eski konumda gibi)
for (const [el, kat] of [[h1, -adim], [h2, adim]]) {
if (!el) continue;
el.style.transition = 'none';
el.style.transform = `translate${eksen}(${kat * 100}%)`;
el.style.zIndex = '5';
}
await new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)));
for (const [el, kat] of [[h1, -adim], [h2, adim]]) {
if (!el) continue;
el.style.transition = `transform ${TAKAS_MS}ms cubic-bezier(.3,1.4,.6,1)`;
el.style.transform = `translate${eksen}(0)`;
}
// KRİTİK: animasyonun FİİLEN bitmesini burada bekle. Aksi halde çağıran
// taraf ayrıca bekle(TAKAS_MS) yapınca süreler çakışır ve takas "takılır".
await bekle(TAKAS_MS);
// süren elemanların geçişini temizle (bir sonraki tahtayiCiz ile çakışmasın)
for (const [el] of [[h1], [h2]]) {
if (!el) continue;
el.style.zIndex = '';
}
void yon;
}
/**
* Zincirleme patlama döngüsü — motor hesaplar, arayüz animasyonla oynatır.
*/
async function patlamaDongusu() {
let tur = 0;
const MAX_TUR = 25;
while (tur < MAX_TUR) {
const gruplar = eslesmeleriBul(S.tahta);
if (gruplar.length === 0) break;
// patlayacak tüm hücreler (özel şeker yayılımı dahil)
const tumSet = new Set();
for (const g of gruplar) {
for (const [r, c] of g.hucreler) tumSet.add(`${r}:${c}`);
}
const yayilmis = patlamaYayilimi(
S.tahta,
[...tumSet].map((k) => k.split(':').map(Number))
);
yayilmis.forEach(([r, c]) => tumSet.add(`${r}:${c}`));
const patlayanlar = [...tumSet].map((k) => k.split(':').map(Number));
// --- patlama animasyonu (BAĞIMSIZ FX KATMANI) ---
// Kök neden: daha önce patlayan şeker doğrudan hücrede animasyona
// sokuluyor, ardından gelen tahtayiCiz() DOM'u sıfırlayıp animasyonu
// YARIDA kesiyordu — oyun "takılıyor", efekt "anıt gibi" donuyordu.
// Çözüm: patlayan şekerin bir KLONU fx katmanına taşınır; klon kendi
// başına 300ms boyunca akıcı oynar, JS tahtayı istediği an çizebilir.
patlayanlar.forEach(([r, c], i) => {
const hucre = document.querySelector(`.cell[data-r="${r}"][data-c="${c}"]`);
const seker = hucre?.querySelector('.candy');
if (!seker) return;
const gecikme = Math.min(i * 12, 90);
const kutu = hucre.getBoundingClientRect();
const boardKutu = board.getBoundingClientRect();
// 1) klonu fx katmanına taşı — hücre çizimi artık onu etkilemez
const klon = seker.cloneNode(true);
klon.classList.remove('patliyor');
klon.classList.add('fx-patliyor');
klon.style.left = `${kutu.left - boardKutu.left}px`;
klon.style.top = `${kutu.top - boardKutu.top}px`;
klon.style.width = `${kutu.width}px`;
klon.style.height = `${kutu.height}px`;
klon.style.animationDelay = `${gecikme}ms`;
fxKatmani().appendChild(klon);
setTimeout(() => klon.remove(), PATLAMA_MS + gecikme + 60);
// 2) halka efektini de fx katmanında bağımsız oynat
const halka = document.createElement('span');
halka.className = 'fx-ring';
const pay = kutu.width * 0.12;
halka.style.left = `${kutu.left - boardKutu.left + pay}px`;
halka.style.top = `${kutu.top - boardKutu.top + pay}px`;
halka.style.width = `${kutu.width - pay * 2}px`;
halka.style.height = `${kutu.height - pay * 2}px`;
halka.style.animationDelay = `${gecikme}ms`;
fxKatmani().appendChild(halka);
setTimeout(() => halka.remove(), 480 + gecikme + 60);
// 3) gerçek şeker anında gizlenir (klon görsel görevi devralır)
seker.style.visibility = 'hidden';
});
sesCal('patlama');
// animasyonun GÖRSEL ritmi sürerken puan mantığı da ilerlesin:
// PATLAMA_MS ile patla animasyonu (300ms) birebir hizalı.
await bekle(PATLAMA_MS);
// --- puan + sayaç + jel ---
let turPuani = 0;
const ozelDogacak = gruplar.filter((g) => g.ozel);
for (const [r, c] of patlayanlar) {
if (!sekerVar(S.tahta, r, c)) continue;
const tip = S.tahta[r][c].tip;
S.sayac[tip] = (S.sayac[tip] || 0) + 1;
turPuani += 60;
const k = `${r}:${c}`;
if (S.jel.has(k)) S.jel.delete(k);
S.tahta[r][c] = null;
}
// grup bonusları
for (const g of gruplar) {
if (g.yon === 'kare') {
// KARE 4'lü: normal 4'lüden değerli. Anında patlar, tahtada kalmaz.
turPuani += 500;
continue;
}
if (g.uzunluk === 4) turPuani += 300;
else if (g.uzunluk >= 5) turPuani += 600;
if (g.yon === 'L') turPuani += 450;
}
// özel şekerleri yerleştir (patlamasın)
// NOT: 'kare' özel şekeri ANINDA patlar (kullanıcı isteği) — yerleştirilmez.
for (const g of ozelDogacak) {
if (g.ozel === 'kare') continue;
const [mr, mc] = g.merkez;
if (!sekerVar(S.tahta, mr, mc)) continue;
if (g.ozel === 'renkli') {
S.tahta[mr][mc] = { tip: g.tip, ozel: 'renkli', hedefTip: g.tip };
} else {
S.tahta[mr][mc] = { tip: g.tip, ozel: g.ozel };
}
}
// --- yerçekimi + yeni şeker ---
// Düşme animasyonu için yerçekimi ÖNCESİ satır konumları saklanır.
const oncekiSatirlar = sekerSatirlariniKaydet();
yerCekimiUygula(S.tahta, () => Math.floor(Math.random() * TIP_SAYISI));
// --- zincir çarpanı ---
if (tur > 0) {
const carpan = tur === 1 ? 1.5 : tur === 2 ? 2 : 2.5;
turPuani = Math.round(turPuani * carpan);
komboGoster(`ZİNCİR x${carpan}!`);
sesCal('kombo');
}
S.skor += turPuani;
if (patlayanlar.length > 0) {
const [pr, pc] = patlayanlar[0];
puanUcusu(pr, pc, turPuani);
}
hudGuncelle();
hedefleriCiz();
yildizCubuguGuncelle();
tahtayiCiz();
// --- gerçek düşme animasyonu (FLIP) ---
const dusmeSuresi = dusmeAnimasyonu(oncekiSatirlar);
await bekle(dusmeSuresi || DUSME_MS);
tur++;
await bekle(ZINCIR_ARASI_MS);
}
}
/**
* Patlama animasyonunun tamamlanması için beklenecek süre (ms).
* CSS `.candy.patliyor` animasyonu (patla) 300ms — ikisi BİREBİR hizalı ki
* DOM yeniden çizildiğinde animasyon YARIDA KESİLMESİN.
*/
const PATLAMA_MS = 300;
/**
* Zincir turu arası ritim (ms).
* "Anıtlar kalmış" geri bildiriminin kökü: tur başına 90ms ölü bekleme,
* 5-6 turluk bir zincirde 500ms+ hissi veriyordu. 45ms'e çekildi —
* cascade hâlâ okunabilir, akış kesintisiz.
*/
const ZINCIR_ARASI_MS = 45;
/** Düşme animasyonu için varsayılan süre (ms). */
const DUSME_MS = 200;
/** Yerçekimi öncesi her hücredeki şekerin satır konumunu saklar (FLIP için). */
function sekerSatirlariniKaydet() {
const harita = [];
for (let r = 0; r < S.tahta.length; r++) {
harita[r] = [];
for (let c = 0; c < S.tahta[0].length; c++) {
harita[r][c] = sekerVar(S.tahta, r, c) ? r : undefined;
}
}
return harita;
}
/**
* Basit bekleme yardımcısı (kısa gecikmeler için).
*/
function bekle(ms) {
return new Promise((res) => setTimeout(res, ms));
}
/** Tahtayı karıştırır ve eşleşme olmadığından emin olur. */
async function tahtayiKaristir() {
komboGoster('KARIŞTIRILDI!');
sesCal('booster');
for (let r = 0; r < S.tahta.length; r++) {
for (let c = 0; c < S.tahta[0].length; c++) {
if (S.tahta[r][c] === 'block') continue;
S.tahta[r][c] = null;
}
}
tahtayiDoldur(S.tahta);
tahtayiCiz();
await bekle(320);
}
// ==========================================================================
// GÜÇLENDİRİCİLER
// ==========================================================================
function boosterSec(id) {
if (S.kilitli || S.bitti) return;
if (S.booster[id] <= 0) return;
S.aktifBooster = S.aktifBooster === id ? null : id;
S.secili = null;
boosterleriCiz();
tahtayiCiz();
if (S.aktifBooster === 'cekir') komboGoster('ÇEKİÇ: şeker seç');
if (S.aktifBooster === 'bomba') komboGoster('BOMBA: merkez seç');
}
async function boosterUygula(id, r, c) {
if (S.kilitli || S.bitti) return;
if (S.booster[id] <= 0) return;
if (S.tahta[r][c] === 'block') return;
S.kilitli = true;
if (id === 'karistir') {
S.booster.karistir--;
S.aktifBooster = null;
boosterleriCiz();
await tahtayiKaristir();
S.kilitli = false;
return;
}
if (!sekerVar(S.tahta, r, c)) {
S.kilitli = false;
return;
}
// temizlenecek hücreleri belirle
let hedefler = [];
if (id === 'cekir') {
hedefler = [[r, c]];
} else if (id === 'bomba') {
for (let rr = r - 1; rr <= r + 1; rr++) {
for (let cc = c - 1; cc <= c + 1; cc++) {
if (rr >= 0 && cc >= 0 && rr < S.tahta.length && cc < S.tahta[0].length) {
if (sekerVar(S.tahta, rr, cc)) hedefler.push([rr, cc]);
}
}
}
}
// patlama yayılımı (zincirleme özel şekerler)
hedefler = patlamaYayilimi(S.tahta, hedefler);
for (const [rr, cc] of hedefler) {
const el = document.querySelector(`.cell[data-r="${rr}"][data-c="${cc}"] .candy`);
if (el) el.classList.add('patliyor');
}
sesCal('booster');
await bekle(220);
let puan = 0;
for (const [rr, cc] of hedefler) {
if (!sekerVar(S.tahta, rr, cc)) continue;
const tip = S.tahta[rr][cc].tip;
S.sayac[tip] = (S.sayac[tip] || 0) + 1;
puan += 60;
S.jel.delete(`${rr}:${cc}`);
S.tahta[rr][cc] = null;
}
S.skor += puan;
S.booster[id]--;
S.aktifBooster = null;
hudGuncelle();
boosterleriCiz();
hedefleriCiz();
yildizCubuguGuncelle();
yerCekimiUygula(S.tahta, () => Math.floor(Math.random() * TIP_SAYISI));
tahtayiCiz();
await bekle(160);
await patlamaDongusu();
if (!hamleVarMi(S.tahta)) await tahtayiKaristir();
S.kilitli = false;
await bolumSonuKontrol();
}
// ==========================================================================
// BÖLÜM AKIŞI
// ==========================================================================
/** Hedeflerin tamamı sağlandı mı? */
function hedeflerTamam() {
const jelSayisi = S.jel.size;
const durumlar = hedefDurumu(S.level, S.skor, S.sayac, jelSayisi);
return durumlar.every((d) => d.tamam);
}
/** Bölüm bitip bitmediğini kontrol eder; bittiyse sonucu gösterir. */
async function bolumSonuKontrol() {
if (S.bitti) return;
const kazandi = hedeflerTamam();
const hamleBitti = S.hamle <= 0;
if (kazandi) {
await bolumuBitir(true);
} else if (hamleBitti) {
await bolumuBitir(false);
}
}
async function bolumuBitir(basarili) {
S.bitti = true;
S.kilitli = true;
const yildiz = basarili ? yildizHesapla(S.skor, S.level.yildizlar) : 0;
// ---- kaydı güncelle ----
if (basarili) {
const mevcut = S.kayit.yildizlar[S.level.id] || 0;
if (yildiz > mevcut) S.kayit.yildizlar[S.level.id] = yildiz;
const enIyi = S.kayit.enIyi[S.level.id] || 0;
if (S.skor > enIyi) S.kayit.enIyi[S.level.id] = S.skor;
// sonraki bölümü aç
if (S.level.id + 1 > S.kayit.acikBolum && S.level.id < 20) {
S.kayit.acikBolum = S.level.id + 1;
}
kayitSakla();
}
// ---- sunucuya skor gönder ----
await skorGonder(S.level.id, 'Sen', S.skor, yildiz);
sesCal(basarili ? 'kazan' : 'kaybet');
// ---- sonuç modalı ----
const yildizKap = $('#resultStars');
yildizKap.innerHTML = '';
for (let i = 1; i <= 3; i++) {
const sp = document.createElement('span');
sp.textContent = '★';
if (i <= yildiz) sp.className = 'on';
yildizKap.appendChild(sp);
}
$('#resultTitle').textContent = basarili
? yildiz === 3
? 'Muhteşem! 🎉'
: 'Bölüm Tamam!'
: 'Hamleler Bitti';
$('#resultScore').textContent = S.skor.toLocaleString('tr-TR');
const btnNext = $('#btnNextLevel');
const sonBolum = S.level.id >= 20;
btnNext.hidden = !basarili || sonBolum;
btnNext.textContent = 'Sonraki Bölüm →';
$('#modalResult').hidden = false;
}
/** Bölümü yükler ve oyunu başlatır. */
async function bolumBaslat(id) {
try {
const veri = await bolumGetir(id);
S.level = veri;
// motor tahtasını kur: blok hücreleri koru, gerisini şekerle doldur
S.tahta = tahtaOlustur(veri.satir, veri.sutun);
for (let r = 0; r < veri.satir; r++) {
for (let c = 0; c < veri.sutun; c++) {
S.tahta[r][c] = veri.tahtaDuzeni?.[r]?.[c] === 'block' ? 'block' : null;
}
}
tahtayiDoldur(S.tahta);
// Başlangıçta hazır eşleşme olmasın.
// DİKKAT: Burada tahtayiKaristir() KULLANILMAZ — o fonksiyon ses çalar,
// "KARIŞTIRILDI!" bandı gösterir ve 320ms bekler. Bölüm açılışında bu
// gereksizdir; döngü 30 tura kadar çıkınca oyun saniyelerce boş kalıyordu.
// Burada DOM'a dokunmayan, sessiz bir yeniden doldurma yapılır;
// tahta zaten tek seferde tahtaKur() ile çizilir.
let guvenlik = 0;
while (eslesmeVarMi(S.tahta) && guvenlik++ < 40) {
for (let r = 0; r < S.tahta.length; r++) {
for (let c = 0; c < S.tahta[0].length; c++) {
if (S.tahta[r][c] === 'block') continue;
S.tahta[r][c] = null;
}
}
tahtayiDoldur(S.tahta);
}
// durum sıfırla
S.jel = new Set(veri.jel || []);
S.sayac = {};
S.skor = 0;
S.hamle = veri.hamle;
S.secili = null;
S.aktifBooster = null;
S.bitti = false;
S.kilitli = false;
S.kombo = 0;
S.booster = boosterPaketi(veri.id);
ekranGoster('game');
$('#btnHome').hidden = false;
tahtaKur();
hudGuncelle();
hedefleriCiz();
boosterleriCiz();
yildizCubuguGuncelle();
} catch (hata) {
// alert() headless/otomasyon ortamlarında JS'i kilitler; hatayı logla + ekranda göster
console.error('[NumexCandy] Bolum yuklenemedi:', hata);
const kutu = document.getElementById('baslatHata');
if (kutu) {
kutu.hidden = false;
kutu.textContent = 'Bölüm yüklenemedi: ' + hata.message;
}
}
}
// ==========================================================================
// EKRAN / MENÜ
// ==========================================================================
function ekranGoster(ad) {
$$('.screen').forEach((el) => el.classList.remove('active'));
const hedef = { menu: '#screenMenu', levels: '#screenLevels', game: '#screenGame' }[ad];
$(hedef)?.classList.add('active');
$('#hud').hidden = ad !== 'game';
$('#btnHome').hidden = ad === 'menu';
$('#modalResult').hidden = true;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function menuyuGuncelle() {
$('#statStars').textContent = toplamYildiz();
$('#statLevel').textContent = S.kayit.acikBolum;
$('#statBest').textContent = enIyiPuan().toLocaleString('tr-TR');
}
function bolumleriCiz() {
const kap = $('#levelsGrid');
kap.innerHTML = '';
let tamamlanan = 0;
for (let i = 1; i <= 20; i++) {
const acik = i <= S.kayit.acikBolum;
const yildiz = S.kayit.yildizlar[i] || 0;
if (yildiz > 0) tamamlanan++;
const kart = document.createElement('button');
kart.className = 'level-card' + (acik ? '' : ' locked') + (yildiz > 0 ? ' completed' : '');
kart.disabled = !acik;
let yildizHtml = '';
if (acik) {
yildizHtml = '<span class="level-stars">';
for (let s = 1; s <= 3; s++) {
yildizHtml += s <= yildiz ? '★' : '<span class="off">★</span>';
}
yildizHtml += '</span>';
}
kart.innerHTML = acik
? `<span class="level-num">${i}</span>${yildizHtml}`
: `<span class="level-lock">🔒</span>`;
kart.title = acik ? `Bölüm ${i}` : 'Kilitli — önceki bölümü bitir';
kart.addEventListener('click', () => bolumBaslat(i));
kap.appendChild(kart);
}
$('#levelsProgress').textContent = `${tamamlanan} / 20 tamamlandı`;
}
/** Skor tablosu modalını açar. */
async function skorTablosuAc() {
const kayitlar = await skorTablosuGetir(S.level.id);
const kap = $('#leaderboardList');
kap.innerHTML = '';
if (kayitlar.length === 0) {
kap.innerHTML = '<li class="bos">Henüz skor kaydı yok.</li>';
} else {
kayitlar.forEach((k, i) => {
const li = document.createElement('li');
li.innerHTML = `
<span class="lb-rank">${i + 1}</span>
<span class="lb-name">${k.isim}</span>
<span class="lb-score">${k.skor.toLocaleString('tr-TR')}</span>
`;
kap.appendChild(li);
});
}
$('#modalBoard').hidden = false;
}
// ==========================================================================
// OLAY BAĞLAMA + BAŞLATMA
// ==========================================================================
function olaylariBagla() {
// ana menü
$('#btnPlay').addEventListener('click', () => {
const id = S.kayit.acikBolum;
bolumBaslat(id);
});
$('#btnLevels').addEventListener('click', () => {
bolumleriCiz();
ekranGoster('levels');
});
$('#btnLevelsBack').addEventListener('click', () => {
menuyuGuncelle();
ekranGoster('menu');
});
// üst bar
$('#btnHome').addEventListener('click', () => {
if (S.kilitli && !S.bitti) return;
menuyuGuncelle();
ekranGoster('menu');
});
$('#btnSound').addEventListener('click', (ev) => {
S.ses = !S.ses;
ev.currentTarget.classList.toggle('off', !S.ses);
ev.currentTarget.textContent = S.ses ? '🔊' : '🔇';
if (S.ses) sesCal('sec');
});
// oyun içi
$('#btnRestart').addEventListener('click', () => {
if (S.level) {
$('#modalResult').hidden = true;
bolumBaslat(S.level.id);
}
});
$('#btnQuit').addEventListener('click', () => {
menuyuGuncelle();
ekranGoster('menu');
});
// sonuç modalı
$('#btnNextLevel').addEventListener('click', () => {
const sonraki = Math.min(20, S.level.id + 1);
$('#modalResult').hidden = true;
bolumBaslat(sonraki);
});
$('#btnRetry').addEventListener('click', () => {
$('#modalResult').hidden = true;
bolumBaslat(S.level.id);
});
$('#btnResultMenu').addEventListener('click', () => {
$('#modalResult').hidden = true;
menuyuGuncelle();
ekranGoster('menu');
});
$('#btnCloseBoard').addEventListener('click', () => {
$('#modalBoard').hidden = true;
});
// klavye kısayolları
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
S.secili = null;
S.aktifBooster = null;
boosterleriCiz();
if (S.tahta.length) tahtayiCiz();
}
if (ev.key === 'r' && ev.ctrlKey && S.level) {
ev.preventDefault();
bolumBaslat(S.level.id);
}
});
// pencere yeniden boyutlanınca tahtayı koru (oransal olduğu için sadece çiz)
window.addEventListener('resize', () => {
if (S.level && !$('#screenGame').hidden) tahtayiCiz();
});
}
/** Uygulamayı başlatır. */
function baslat() {
kayitYukle();
olaylariBagla();
menuyuGuncelle();
ekranGoster('menu');
}
baslat();