GCTCG Helper

Gamescom EPIX Trading Cards Helper

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         GCTCG Helper
// @namespace    https://github.com/MixingFlow/GCTCG-Helper
// @version      1.0.1
// @description  Gamescom EPIX Trading Cards Helper
// @author       MixingFlow
// @match        *://*.gamescom.global/*
// @match        *://gamescom.global/*
// @icon         https://www.gamescom.global/static/meta/favicon.ico
// @license      MIT
// @grant        GM_setClipboard
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const DB_URL = 'https://raw.githubusercontent.com/MixingFlow/GCTCG-Helper/main/cards/2026.json';
  const IMG = 'https://eu-central-1-gamescom.graphassets.com/AMwDHZTUSMaIlRlMFLL7Qz/quality=value:95/resize=w:320,h:494,fit:crop/sharpen=amount:1/auto_image/';
  const $ = (s, c = document) => c.querySelector(s);
  const $$ = (s, c = document) => [...c.querySelectorAll(s)];
  const de = () => document.documentElement.lang === 'de' || location.pathname.startsWith('/de');
  const isCardsPage = () => location.pathname.includes('/epix/cards');
  const log = (...a) => console.log('%c[GCTCG]', 'background:#7c3aed;color:#fff;padding:2px 5px;border-radius:3px;font-weight:bold', ...a);

  let DB = [], byHash = new Map(), byName = new Map(), lastSig = '', observer, timer;

  function placeholderSvg(num, lbl) {
    return 'data:image/svg+xml,' + encodeURIComponent([
      '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="494">',
      '<defs><linearGradient id="b" x2="100%" y2="100%">',
      '<stop stop-color="#24123a"/><stop offset="100%" stop-color="#12071f"/>',
      '</linearGradient></defs>',
      '<rect width="320" height="494" rx="8" fill="url(#b)"/>',
      '<rect x="12" y="12" width="296" height="470" rx="6" fill="none" stroke="#6b21a8" stroke-width="2" stroke-dasharray="8,6"/>',
      '<circle cx="160" cy="205" r="34" fill="#3b0764" stroke="#7e22ce" stroke-width="2"/>',
      '<text x="160" y="217" fill="#d8b4fe" font-family="system-ui" font-size="32" font-weight="bold" text-anchor="middle">?</text>',
      `<text x="160" y="270" fill="#faf5ff" font-family="system-ui" font-size="17" font-weight="bold" text-anchor="middle" letter-spacing="1.5">${lbl.toUpperCase()}</text>`,
      `<text x="160" y="300" fill="#c084fc" font-family="system-ui" font-size="15" font-weight="600" text-anchor="middle">${num}</text>`,
      '</svg>'
    ].join(''));
  }

  function prepCard(el, src, alt, clickTarget) {
    el.setAttribute('data-gctcg', '');
    const img = $$('img', el);
    if (img.length) {
      const m = img.find(i => i.classList.contains('image--img')) || img[0];
      ['srcset', 'sizes', 'data-nimg'].forEach(a => m.removeAttribute(a));
      Object.assign(m.style, { filter: '', opacity: '1', visibility: 'visible', color: 'transparent' });
      m.className = 'image--img is--loaded';
      m.src = src;
      m.alt = alt || '';
      img.forEach(i => i !== m && i.remove());
    }
    const vid = $('video', el);
    if (vid) {
      vid.muted = vid.playsInline = vid.loop = vid.autoplay = true;
      setTimeout(() => vid.play().catch(() => {}), 50);
    }
    if (clickTarget) el.onclick = e => { e.preventDefault(); e.stopPropagation(); clickTarget.click(); };
    return el;
  }

  function section(id, title, pillCls, badge, cards = [], extra = '') {
    const s = document.createElement('section');
    s.id = id;
    s.setAttribute('data-gctcg-section', '');
    s.innerHTML = `
      <div class="gctcg-hdr d-flex justify-content-between align-items-center flex-wrap gap-2">
        <h2 class="gctcg-title d-flex align-items-center gap-2">
          ${title} <span class="gctcg-pill ${pillCls}">${badge}</span>
        </h2>
        ${extra}
      </div>`;
    if (cards.length) {
      const g = document.createElement('div');
      g.className = 'row gx-3 gy-4 g-sm-4';
      cards.forEach(c => g.appendChild(c));
      s.appendChild(g);
    }
    return s;
  }

  function render() {
    if (!isCardsPage()) { lastSig = ''; return; }
    if (!DB.length) return;

    const cards = $$('.card-list--list-item:not([data-gctcg-section] *)');
    if (!cards.length) return;

    const container = cards[0].parentElement;
    const native = [...container.children].filter(e => !e.hasAttribute('data-gctcg'));
    if (!native.length) return;

    if (!$('#gctcg-styles')) document.head.insertAdjacentHTML('beforeend', `<style id="gctcg-styles">${CSS}</style>`);

    // Scan owned cards
    const owned = new Map();
    native.forEach((el, i) => {
      if ($('.card-tile--is-locked', el)) return;
      const img = $('img', el);
      if (!img) return;
      const alt = (img.alt || '').trim(), src = img.src || '', hash = src.split('/').pop().split('?')[0];
      const count = ($('.card-list--list-item-count', el) || el).textContent.match(/x\s*(\d+)/i);
      const db = (hash && byHash.get(hash)) || (alt && byName.get(alt.toLowerCase()));
      owned.set(db?.id || hash || alt || `unknown-${i}`, {
        dbCard: db || { num: '??', id: 0, name: alt || 'Unknown', imageId: hash },
        count: count ? +count[1] : 1, el, alt, src, hash
      });
    });

    const sig = [...owned].map(([k, v]) => `${k}:${v.count}`).sort().join();
    if (!owned.size || (sig === lastSig && $('#gctcg-dup'))) return;
    lastSig = sig;

    // Compute dups & missing
    const dups = [], miss = DB.filter(c => !owned.has(c.id));
    let ext = 0;
    owned.forEach(v => { if (v.count > 1) { dups.push(v); ext += v.count - 1; } });
    dups.sort((a, b) => (a.dbCard.id || 0) - (b.dbCard.id || 0));

    // Rebuild sections
    observer?.disconnect();
    $$('[data-gctcg-section]').forEach(e => e.remove());
    const g = de();

    const dupEls = dups.map(d => prepCard(d.el.cloneNode(true), d.hash ? IMG + d.hash : d.src, d.alt, $('[role="button"],button,a,img', d.el) || d.el));
    const missEls = miss.map(c => {
      const el = cards[0].cloneNode(true);
      el.setAttribute('data-gctcg-miss', '');
      $$('.card-tile--is-advertised, [class*="list-item-count"]', el).forEach(n => n.remove());
      const unk = !c.imageId || c.name.startsWith('Card ');
      const lbl = g ? 'Unveröffentlicht' : 'Unreleased';
      el.title = `${c.num}: ${unk ? lbl : c.name}`;
      return prepCard(el, c.imageId ? IMG + c.imageId : placeholderSvg(c.num, lbl), unk ? lbl : c.name);
    });

    const copyBtn = `<button id="gctcg-copy" class="gctcg-copy">${g ? 'Tauschliste kopieren' : 'Copy Trade List'}</button>`;
    container.before(
      section('gctcg-dup', g ? 'Doppelte Karten' : 'Duplicate Cards', 'gctcg-pill-g', `${dups.length} (+${ext})`, dupEls, copyBtn),
      section('gctcg-miss', g ? 'Fehlende Karten' : 'Missing Cards', 'gctcg-pill-r', `${miss.length}/${DB.length}`, missEls),
      section('gctcg-own', g ? 'Alle Karten' : 'All Cards', 'gctcg-pill-p', `${owned.size}`)
    );

    // Copy handler
    $('#gctcg-copy')?.addEventListener('click', () => {
      const want = miss.filter(m => m.imageId && !m.name.startsWith('Card ')).map(m => m.num.split('-')[1]);
      const have = dups.map(d => `${d.dbCard.num} - ${d.dbCard.name}`).join('\n') || (g ? 'Keine' : 'None');
      const t = `__**${g ? 'BIETE' : 'HAVE'}**__\n\n${have}\n\n__**${g ? 'BRAUCHE' : 'WANT'}**__ (2026 ${g ? 'Karten' : 'cards'})\n\n${want.join(', ') || (g ? 'Keine' : 'None')}`;
      if (window.GM_setClipboard) GM_setClipboard(t); else navigator.clipboard?.writeText(t);
      document.body.insertAdjacentHTML('beforeend', `<div class="gctcg-toast">${g ? 'Kopiert!' : 'Copied!'}</div>`);
      setTimeout(() => $('.gctcg-toast')?.remove(), 2500);
    });

    log(`${owned.size} owned, ${dups.length} dups, ${miss.length} missing`);
    observer?.observe(document.body, { childList: true, subtree: true });
  }

  const CSS = `
    [data-gctcg-section] { margin: 2rem 0 }
    .gctcg-hdr { border-bottom: 2px solid rgba(255,255,255,.15); padding-bottom: .75rem; margin-bottom: 1.25rem }
    .gctcg-title { font-size: 1.5rem; font-weight: 700; color: #fff; margin: 0 }
    .gctcg-pill { font-size: .8rem; font-weight: 600; padding: .2rem .65rem; border-radius: 999px; border: 1px solid }
    .gctcg-pill-g { background: rgba(16,185,129,.2); border-color: rgba(16,185,129,.4); color: #34d399 }
    .gctcg-pill-r { background: rgba(239,68,68,.2); border-color: rgba(239,68,68,.4); color: #f87171 }
    .gctcg-pill-p { background: rgba(168,85,247,.2); border-color: rgba(168,85,247,.4); color: #c084fc }
    [data-gctcg-miss] img { filter: grayscale(1) brightness(.85) contrast(1.05) !important; opacity: 1 !important; transition: filter .25s }
    [data-gctcg-miss]:hover img { filter: grayscale(.25) brightness(.98) !important }
    [data-gctcg-miss] video { filter: grayscale(1) brightness(.8) !important; opacity: 1 !important; transition: filter .25s }
    [data-gctcg-miss]:hover video { filter: none !important }
    .gctcg-copy { cursor: pointer; font-size: .85rem; font-weight: 600; padding: .35rem .8rem; border-radius: 6px; background: #7c3aed; border: 1px solid #8b5cf6; color: #fff }
    .gctcg-toast { position: fixed; bottom: 24px; right: 24px; background: #0f172a; border: 1px solid #10b981; color: #fff; padding: 10px 18px; border-radius: 8px; z-index: 999999 }`;

  const trigger = () => { clearTimeout(timer); timer = setTimeout(render, 250); };
  observer = new MutationObserver(m => m.some(x => x.addedNodes.length && !x.target.closest?.('[data-gctcg-section]')) && trigger());
  observer.observe(document.body, { childList: true, subtree: true });

  // Hook Next.js SPA navigation
  ['pushState', 'replaceState'].forEach(method => {
    const original = history[method];
    history[method] = function (...args) {
      const result = original.apply(this, args);
      trigger();
      return result;
    };
  });
  window.addEventListener('popstate', trigger);

  fetch(DB_URL).then(r => r.json()).then(res => {
    DB = res.map((r, i) => {
      const [id, name, hash] = Array.isArray(r) ? r : [r.id, r.name, r.imageId];
      const num = `26-${String(id || i + 1).padStart(2, '0')}`;
      return { id: id || i + 1, name: name || `Card ${num}`, imageId: hash || '', num };
    });
    DB.forEach(c => { if (c.imageId) byHash.set(c.imageId, c); if (c.name) byName.set(c.name.toLowerCase(), c); });
    log(`Loaded ${DB.length} cards`);
    trigger();
  }).catch(e => log('DB load failed', e));
})();