/* Trang danh sách game (/game, game/danhmuc.php) — trước đây là script nhúng * cuối danhmuc.php. Giữ nguyên logic; khác bản cũ: * - selector chỉ tìm trong nội dung trang (root), không lẫn sang trang khác; * - sự kiện cuộn của window, IntersectionObserver gỡ khi rời trang; * - kết quả AJAX / hẹn giờ về sau khi đã rời trang thì bỏ qua. */ WGNav.page('game-list', function (root, ctx) { var $ = window.jQuery; function $q(sel) { return $(root).find(sel); } function alive() { return ctx.alive; } // ================== CONFIG ================== // Sửa path API cho đúng dự án của bạn: var API = root.querySelector('#wgGameDir') ? root.querySelector('#wgGameDir').getAttribute('data-api') : '/game/list.php'; // Build A–Z nav (function buildAlpha(){ var nav = $q('#alphaNav'); nav.append('
  • #
  • '); for (var i=65;i<=90;i++){ var ch = String.fromCharCode(i); nav.append('
  • '+ch+'
  • '); } // Ban đầu: tất cả mờ đi $q('#alphaNav a').addClass('disabled'); })(); // State var state = { q:'', page:1, per_page:24, loading:false, has_more:true }; // ===== Helpers ===== function debounce(fn, wait){ var t; return function(){ var that=this, args=arguments; clearTimeout(t); t=setTimeout(function(){ if (alive()) fn.apply(that,args); }, wait); }; } function esc(s){ return String(s || '').replace(/[&<>"']/g, function(m){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]; }); } function avatar(img){ return '' } function firstLetter(name){ var s=(name||'').trim(); var m=s.match(/[A-Za-z0-9]/); if(!m) return '#'; var c=m[0]; return /[0-9]/.test(c) ? '#' : c.toUpperCase(); } function orderLetters(){ var arr=['#']; for (var i=65;i<=90;i++) arr.push(String.fromCharCode(i)); return arr; } // Cập nhật trạng thái enable/disable cho A–Z dựa trên group đã có function updateAlphaState(){ $q('#alphaNav a').each(function(){ var L=$(this).data('letter'); var letter=(L==='#')?'#':L; if (root.querySelector('[data-group="'+letter+'"]')) { $(this).removeClass('disabled'); } else { $(this).addClass('disabled').removeClass('active'); } }); } function setLetterActive(letter){ $q('#alphaNav a').removeClass('active'); $q('#alphaNav a[data-letter="'+letter+'"]').addClass('active'); } // Tạo group khi có data và CHÈN ĐÚNG THỨ TỰ #, A..Z function ensureGroup(letter){ var $g = $q('[data-group="'+letter+'"]'); if ($g.length) return $g; var html = '
    '+ '
    '+letter+'
    '+ '
    '+ '
    '; var order=orderLetters(), idx=order.indexOf(letter), inserted=false; for (var i=idx+1;i'+ ''+ avatar(c.icon)+'
    '+c.name+'
    '+ '
    '+ ''; } function card(c){ return '
    '+ ''+ avatar(c.icon)+'
    '+c.name+'
    '+ '
    '+ '
    '; } // Render batch: tạo group cho chữ có data function renderAppend(items){ if (!items || !items.length) return; var map = {}; items.forEach(function(it){ var L = (it.letter || firstLetter(it.name)); (map[L] = map[L] || []).push(it); }); orderLetters().forEach(function(L){ if (!map[L]) return; var $group = ensureGroup(L); var $list = $group.find('.group-list'); map[L].forEach(function(it){ $list.append(rowItem(it)); }); }); updateAlphaState(); } // Spinner function showSpinner(){ if ($q('#infiniteSpinner').length) return; $q('#sentinel').before('
    Đang tải...
    '); } function hideSpinner(){ $q('#infiniteSpinner').remove(); } // Cuộn tới group (cách top 50px) & set active function scrollToGroup(letter){ var el=root.querySelector('[data-group="'+letter+'"]'); if (el){ var offset=el.getBoundingClientRect().top + window.scrollY - 80; window.scrollTo({top:offset, behavior:'smooth'}); setLetterActive(letter); } } function groupExists(letter){ return root.querySelector('[data-group="'+letter+'"]'); } // Nhảy tới chữ (nếu chưa có thì pending & auto-load) var pendingLetter=null; function ensureLetterVisible(letter){ if (groupExists(letter)) { scrollToGroup(letter); pendingLetter=null; return; } pendingLetter = letter; if (state.has_more && !state.loading) loadGrouped(false); } function afterLoadTryScroll(){ if (!pendingLetter) return; if (groupExists(pendingLetter)) { scrollToGroup(pendingLetter); pendingLetter=null; } else if (state.has_more && !state.loading) { loadGrouped(false); } else { pendingLetter=null; } } // ===== BỘ NHỚ TÊN GAME (phục vụ gợi ý tìm kiếm) ===== var allGameNames = []; // {id, name, seo, icon} var popularGames = []; // lấy từ dải nổi bật function cacheGames(items){ (items || []).forEach(function(it){ if (!allGameNames.some(function(g){ return g.id === it.id; })) { allGameNames.push({ id:it.id, name:it.name, seo:it.seo, icon:it.icon }); } }); } // ===== TÌM THEO CHỮ CÁI ĐẦU (FGO = Fate Grand Order) ===== // Các từ nối bị bỏ qua khi lấy chữ cái đầu var STOP_WORDS = ['of','the','and','a','an','to','in','for','de','la','le']; // Sinh các biến thể viết tắt của một tên game function makeAcronyms(name){ var clean = String(name || '') .replace(/[^\w\s:\-']/g, ' ') // bỏ ký tự lạ .replace(/[:\-']/g, ' ') .trim(); if (!clean) return []; var words = clean.split(/\s+/).filter(function(w){ return w.length > 0; }); if (!words.length) return []; var out = []; // 1) Chữ cái đầu của MỌI từ: Fate Grand Order -> FGO out.push(words.map(function(w){ return w.charAt(0); }).join('').toLowerCase()); // 2) Bỏ từ nối: Goddess of Victory Nikke -> GVN var kept = words.filter(function(w){ return STOP_WORDS.indexOf(w.toLowerCase()) === -1; }); if (kept.length && kept.length !== words.length){ out.push(kept.map(function(w){ return w.charAt(0); }).join('').toLowerCase()); } // 3) Giữ nguyên số: Reverse 1999 -> R1999 var withNum = words.map(function(w){ return /^\d+$/.test(w) ? w : w.charAt(0); }).join('').toLowerCase(); if (out.indexOf(withNum) === -1) out.push(withNum); // 4) Bỏ qua từ đầu nếu là tên hãng: Honkai Star Rail -> HSR va SR if (words.length > 2){ out.push(words.slice(1).map(function(w){ return w.charAt(0); }).join('').toLowerCase()); } // 5) Bỏ 1 từ ở giữa: Uma Musume Pretty Derby -> UMD, UPD if (words.length >= 3 && words.length <= 5){ for (var k = 1; k < words.length - 1; k++){ var sub = words.slice(0, k).concat(words.slice(k + 1)); out.push(sub.map(function(w){ return w.charAt(0); }).join('').toLowerCase()); } } return out.filter(function(v, i, arr){ return v.length >= 2 && arr.indexOf(v) === i; }); } // Từ khoá có khớp viết tắt của game này không? function matchAcronym(q, name){ var ql = String(q || '').toLowerCase().replace(/[^\w]/g, ''); if (ql.length < 2) return false; var accs = makeAcronyms(name); for (var i = 0; i < accs.length; i++){ if (accs[i] === ql) return true; // khớp hoàn toàn: FGO if (accs[i].indexOf(ql) === 0) return true; // khớp phần đầu: FG -> FGO } return false; } // Lọc game theo viết tắt trong danh sách đã cache function searchByAcronym(q, limit){ limit = limit || 12; var hits = allGameNames.filter(function(g){ return matchAcronym(q, g.name); }); return hits.slice(0, limit); } // Khoảng cách Levenshtein - đo độ giống nhau giữa 2 chuỗi function levenshtein(a, b){ a = a.toLowerCase(); b = b.toLowerCase(); var m = a.length, n = b.length; if (!m) return n; if (!n) return m; var prev = [], cur = [], i, j; for (j = 0; j <= n; j++) prev[j] = j; for (i = 1; i <= m; i++){ cur[0] = i; for (j = 1; j <= n; j++){ cur[j] = Math.min( prev[j] + 1, cur[j-1] + 1, prev[j-1] + (a.charAt(i-1) === b.charAt(j-1) ? 0 : 1) ); } for (j = 0; j <= n; j++) prev[j] = cur[j]; } return prev[n]; } // Tìm game gần giống nhất với từ khoá gõ sai function findSimilar(q, limit){ limit = limit || 3; var ql = q.toLowerCase().trim(); if (!ql) return []; // Ưu tiên tuyệt đối: khớp viết tắt (FGO -> Fate Grand Order) var byAcr = searchByAcronym(ql, limit); if (byAcr.length) { if (byAcr.length >= limit) return byAcr; } var scored = allGameNames.map(function(g){ var nl = g.name.toLowerCase(); var dist = levenshtein(ql, nl); var bonus = 0; if (nl.indexOf(ql) !== -1) { bonus = -8; // tên game có chứa nguyên từ khoá } else { var words = nl.split(/[\s:\-]+/); // so với từng từ trong tên for (var i = 0; i < words.length; i++){ var d = levenshtein(ql, words[i]); if (d < dist) dist = d; if (words[i].indexOf(ql) === 0) bonus = -5; } } return { g:g, score: dist + bonus }; }); var maxDist = Math.max(3, Math.floor(ql.length * 0.55)); var fuzzy = scored .filter(function(s){ return s.score <= maxDist; }) .sort(function(a,b){ return a.score - b.score; }) .map(function(s){ return s.g; }); // Ghép: viết tắt trước, rồi mới tới gần giống — không trùng lặp var merged = byAcr.slice(); fuzzy.forEach(function(g){ if (!merged.some(function(m){ return m.id === g.id; })) merged.push(g); }); return merged.slice(0, limit); } function pickPopular(limit){ limit = limit || 6; var src = popularGames.length ? popularGames : allGameNames; return src.slice(0, limit); } // Khối "không tìm thấy" kèm gợi ý function renderNoResult(q){ var similar = findSimilar(q, 3); var popular = pickPopular(6); var html = '
    '; html += '
    ' + '' + '' + '' + '
    '; html += '
    Không tìm thấy “' + esc(q) + '”
    '; if (similar.length){ html += '
    Có phải bạn đang tìm?
    '; html += '
    '; similar.forEach(function(g){ html += '' + (g.icon ? '' : '') + ''+esc(g.name)+'' + ''; }); html += '
    '; } else { html += '
    Thử kiểm tra lại chính tả, hoặc tìm với từ khoá ngắn hơn.
    '; } if (popular.length){ html += '
    '; html += '
    Game được tìm nhiều
    '; html += '
    '; popular.forEach(function(g){ html += '' + (g.icon ? '' : '') + ''+esc(g.name)+'' + ''; }); html += '
    '; } html += ''; html += '
    '; return html; } // Bật/tắt chế độ đang tìm kiếm function setSearchMode(on, q){ if (on){ $q('#hotStripWrap').hide(); $q('#newGrid').hide(); $q('#newGrid').prev('.section-header').hide(); $q('#searchHead').show().find('.wg-sh-q').text(q); } else { if ($q('#hotTrack').children().length) $q('#hotStripWrap').show(); $q('#newGrid').show(); $q('#newGrid').prev('.section-header').show(); $q('#searchHead').hide(); } } // ===== API calls ===== function loadNew(){ $.getJSON(API, {section:'new', per_page:12}) .done(function(res){ if (!ctx.alive) return; cacheGames(res.items); var html=(res.items||[]).map(card).join(''); $q('#newGrid').html(html || '
    Không có mục mới.
    '); }) .fail(function(xhr){ console.error('AJAX new error', xhr.status, xhr.responseText); }); } function loadGrouped(reset){ if (!ctx.alive || state.loading) return; state.loading = true; if (reset){ state.page = 1; state.has_more = true; $q('#groupedList').empty(); // không build sẵn group hideSpinner(); // mờ lại tất cả chữ $q('#alphaNav a').removeClass('active').addClass('disabled'); } else { showSpinner(); } $.getJSON(API, { section:'all', q:state.q, page:state.page, per_page:state.per_page }) .done(function(res){ if (!ctx.alive) return; cacheGames(res.items); renderAppend(res.items || []); state.has_more = !!res.has_more; if (state.has_more) state.page += 1; if (reset && (!res.items || !res.items.length) && !state.has_more) { if (state.q){ // Server không ra kết quả -> thử khớp VIẾT TẮT trong danh sách đã cache var acrHits = searchByAcronym(state.q, 24); if (acrHits.length){ $q('#groupedList').empty(); renderAppend(acrHits); } else { $q('#groupedList').html(renderNoResult(state.q)); } } else { $q('#groupedList').append('

    Không có kết quả.

    '); } } }) .fail(function(xhr){ console.error('AJAX all error', xhr.status, xhr.responseText); }) .always(function(){ if (!ctx.alive) return; state.loading = false; hideSpinner(); afterLoadTryScroll(); }); } // ===== Infinite scroll ===== var io = new IntersectionObserver(function(entries){ if (entries[0].isIntersecting && state.has_more && !state.loading) { loadGrouped(false); } }, { root:null, rootMargin:'800px 0px', threshold:0 }); io.observe(root.querySelector('#sentinel')); ctx.cleanup(function(){ io.disconnect(); }); // Fallback scroll function checkScroll(){ if (!state.has_more || state.loading) return; var nearBottom = $(window).scrollTop()+$(window).height() >= $(document).height()-600; if (nearBottom) loadGrouped(false); } ctx.on(window, 'scroll', debounce(checkScroll, 120), { passive: true }); // ===== Events ===== // Tìm kiếm (giữ IME cho TV): compositionend -> input $q('#searchInput') .on('compositionend', function(){ $(this).trigger('input'); }) .on('input keyup change', debounce(function(e){ var val = (e.target && e.target.value) ? e.target.value.trim() : ''; state.q = val; pendingLetter = null; setSearchMode(!!val, val); if ($q('#groupedList').offset()) { $('html,body').scrollTop( $q('#groupedList').offset().top - 90 ); } loadGrouped(true); }, 250)); // Nút "Xoá tìm kiếm" trong khối không có kết quả ctx.on(root, 'click', '#wgClearSearch', function(){ $q('#searchInput').val('').trigger('input').focus(); }); // Nút X trong ô tìm kiếm $q('#searchClear').on('click', function(){ $q('#searchInput').val('').trigger('input').focus(); }); // Hiện/ẩn nút X theo nội dung ô nhập $q('#searchInput').on('input', function(){ $(this).closest('.search-box').toggleClass('has-value', $(this).val().length > 0); }); // Phím Esc để xoá nhanh $q('#searchInput').on('keydown', function(e){ if (e.key === 'Escape' && $(this).val()){ $(this).val('').trigger('input'); } }); // Click A–Z: chỉ click khi đã enable $q('#alphaNav').on('click', 'a:not(.disabled)', function(e){ e.preventDefault(); var L=$(this).data('letter'); var letter=(L==='#')?'#':L; setLetterActive(letter); ensureLetterVisible(letter); }); // ===== DẢI GAME NỔI BẬT ===== function hotItem(c){ var img = c.icon || 'https://via.placeholder.com/74?text=?'; var name = esc(c.name); var url = '/game/' + (c.seo || c.id); return ''+ ''+name+''+ '
    '+name+'
    '+ '
    '; } function loadHotStrip(){ $.getJSON(API, { section:'hot', per_page:20 }) .done(function(res){ if (!ctx.alive) return; var items = res.items || []; if (!items.length) return; // không có game nổi bật -> giữ ẩn cacheGames(items); popularGames = items.slice(); // dùng làm "game được tìm nhiều" // Nhân đôi để vòng lặp cuộn liền mạch var html = items.map(hotItem).join(''); $q('#hotTrack').html(html + html); // Tốc độ theo số lượng game var dur = Math.max(25, items.length * 3); $q('#hotTrack').css('animation-duration', dur + 's'); if (!state.q) $q('#hotStripWrap').show(); }) .fail(function(xhr){ console.error('AJAX hot error', xhr.status, xhr.responseText); }); } // ===== Init ===== loadHotStrip(); loadNew(); loadGrouped(true); });