🎯🎨 Chess.com Custom Arrows

✨ Personalize suas setas com controle total sobre cores, gradientes, espessura, opacidade e tamanho da ponta. ♟️🔥

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         🎯🎨 Chess.com Custom Arrows
// @namespace    http://tampermonkey.net/
// @version      7.2
// @description  ✨ Personalize suas setas com controle total sobre cores, gradientes, espessura, opacidade e tamanho da ponta. ♟️🔥
// @author       aerus15
// @match        *://*.chess.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 1. ESTADO GLOBAL E SALVAMENTO (LOCALSTORAGE)
    // ==========================================
    const savedGrad = localStorage.getItem('vfx-arrow-use-grad');

    const STATE = {
        thickness: parseFloat(localStorage.getItem('vfx-arrow-thick')) || 15,
        useGradient: savedGrad !== null ? savedGrad === 'true' : true,
        color1: localStorage.getItem('vfx-arrow-c1') || '#ff0000',
        color2: localStorage.getItem('vfx-arrow-c2') || '#ffff00',
        color3: localStorage.getItem('vfx-arrow-c3') || '#00ff00',
        solidColor: localStorage.getItem('vfx-arrow-solid') || '#ffaa00',
        opacity: parseFloat(localStorage.getItem('vfx-arrow-opacity')) || 0.8,

        headSize: parseFloat(localStorage.getItem('vfx-arrow-hsize')) || 1.0,
        headWidth: parseFloat(localStorage.getItem('vfx-arrow-hwidth')) || 1.0,
        headLength: parseFloat(localStorage.getItem('vfx-arrow-hlength')) || 1.0,

        drawnShapes: [],
        currentShape: null,
        isDragging: false,
        boardElement: null,
        svgOverlay: null,
        squareSize: 0,
        isFlipped: false
    };

    // ==========================================
    // 2. CSS PARA ANULAR SETAS NATIVAS E BLINDAR CONTAINER
    // ==========================================
    const cssOverride = `
        /* Oculta APENAS caminhos e polígonos das setas nativas (preserva os círculos e quadrados de outras extensões) */
        wc-chess-board .arrows path,
        wc-chess-board .arrows line,
        wc-chess-board .arrows polyline,
        wc-chess-board .arrows polygon,
        [data-cy="arrows"] path,
        [data-cy="arrows"] line,
        [data-cy="arrows"] polyline,
        [data-cy="arrows"] polygon,
        svg.arrows path,
        svg.arrows line,
        svg.arrows polyline,
        svg.arrows polygon {
            display: none !important;
            opacity: 0 !important;
            visibility: hidden !important;
            pointer-events: none !important;
        }

        #vfx-custom-arrows-container {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            pointer-events: none;
            /* Z-index extremo para garantir que fique em cima de TODAS as peças e marcações do outro script */
            z-index: 2147483650 !important;
        }

        #vfx-master-panel {
            position: fixed;
            right: 15px;
            bottom: 55px; /* Movido ~1cm para cima */
            background: rgba(15, 15, 20, 0.95);
            border: 1px solid #00e5ff;
            border-radius: 8px;
            padding: 15px;
            z-index: 2147483647;
            font-family: system-ui, sans-serif;
            color: white;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 229, 255, 0.2);
            backdrop-filter: blur(10px);
            display: flex;
            flex-direction: column;
            gap: 12px;
            width: 320px;
            max-height: 85vh;
            overflow-y: auto;
            transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
            transform-origin: bottom right;
        }

        #vfx-master-panel::-webkit-scrollbar { width: 6px; }
        #vfx-master-panel::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); }
        #vfx-master-panel::-webkit-scrollbar-thumb { background: #00e5ff; border-radius: 3px; }

        #vfx-master-panel.minimized {
            transform: scale(0);
            pointer-events: none;
        }

        #vfx-close-btn {
            position: absolute;
            top: 10px;
            right: 12px;
            color: #ff4444;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            user-select: none;
            line-height: 1;
            transition: 0.2s;
        }
        #vfx-close-btn:hover {
            color: #ff0000;
            transform: scale(1.2);
        }

        .vfx-row {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .vfx-label {
            font-size: 11px;
            font-weight: 600;
            text-transform: uppercase;
            letter-spacing: 0.5px;
            color: #ccc;
            flex: 1;
        }
        .vfx-input {
            background: #222;
            border: 1px solid #444;
            color: white;
            border-radius: 4px;
            padding: 4px;
            outline: none;
        }
        input[type="color"].vfx-input {
            height: 28px;
            width: 40px;
            padding: 0;
            cursor: pointer;
        }
        input[type="range"].vfx-slider {
            width: 110px;
            accent-color: #00e5ff;
            cursor: pointer;
            margin-right: 8px;
        }
        .vfx-val-display {
            width: 30px;
            text-align: right;
            font-size: 11px;
            font-weight: bold;
            color: #00e5ff;
            font-family: monospace;
        }
        .vfx-toggle {
            cursor: pointer;
            background: #333;
            border: 1px solid #555;
            padding: 6px 10px;
            border-radius: 4px;
            font-size: 11px;
            font-weight: bold;
            transition: 0.2s;
            text-align: center;
            width: 100%;
            margin-top: 5px;
            user-select: none;
        }
        .vfx-toggle.active {
            background: #00e5ff;
            color: #000;
            border-color: #00e5ff;
        }
        .vfx-divider {
            height: 1px;
            background: rgba(255,255,255,0.1);
            margin: 5px 0;
        }
        #vfx-floating-btn {
            position: fixed;
            right: 15px;
            bottom: 55px; /* Movido ~1cm para cima */
            width: 45px;
            height: 45px;
            background: #111;
            border: 2px solid #00e5ff;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 20px;
            cursor: pointer;
            z-index: 2147483648;
            box-shadow: 0 0 15px rgba(0,229,255,0.4);
            user-select: none;
            transition: all 0.2s ease;
        }
        #vfx-floating-btn:hover {
            transform: scale(1.1);
            background: #222;
        }
    `;

    const styleEl = document.createElement('style');
    styleEl.textContent = cssOverride;
    (document.head || document.documentElement).appendChild(styleEl);

    // ==========================================
    // 3. MATEMÁTICA DO TABULEIRO
    // ==========================================
    const FILES = ['a','b','c','d','e','f','g','h'];

    function getBoardState() {
        if (!STATE.boardElement) return;
        const rect = STATE.boardElement.getBoundingClientRect();
        STATE.squareSize = rect.width / 8;
        STATE.isFlipped = STATE.boardElement.classList.contains('flipped');
    }

    function pointToSquare(x, y) {
        if (!STATE.boardElement || STATE.squareSize === 0) return null;
        let fileIdx = Math.floor(x / STATE.squareSize);
        let rankIdx = Math.floor(y / STATE.squareSize);

        if (fileIdx < 0) fileIdx = 0;
        if (fileIdx > 7) fileIdx = 7;
        if (rankIdx < 0) rankIdx = 0;
        if (rankIdx > 7) rankIdx = 7;

        if (STATE.isFlipped) {
            fileIdx = 7 - fileIdx;
        } else {
            rankIdx = 7 - rankIdx;
        }
        return FILES[fileIdx] + (rankIdx + 1);
    }

    function squareToPoint(sq) {
        if (!STATE.boardElement || STATE.squareSize === 0 || !sq) return {x: 0, y: 0};
        const fileIdx = FILES.indexOf(sq[0]);
        const rankIdx = parseInt(sq[1]) - 1;

        let visualFile, visualRank;

        if (STATE.isFlipped) {
            visualFile = 7 - fileIdx;
            visualRank = rankIdx;
        } else {
            visualFile = fileIdx;
            visualRank = 7 - rankIdx;
        }

        return {
            x: (visualFile + 0.5) * STATE.squareSize,
            y: (visualRank + 0.5) * STATE.squareSize
        };
    }

    // ==========================================
    // 4. MOTOR DE DESENHO (SVG)
    // ==========================================
    function setupSVG() {
        if (!STATE.boardElement) return;

        let container = STATE.boardElement.querySelector('#vfx-custom-arrows-container');
        if (!container) {
            container = document.createElement('div');
            container.id = 'vfx-custom-arrows-container';
            STATE.boardElement.appendChild(container);
        }

        // SVG Container - A opacidade é aplicada DIRETAMENTE AQUI
        container.innerHTML = `
            <svg id="vfx-main-svg" width="100%" height="100%" style="overflow: visible; opacity: ${STATE.opacity};">
                <defs>
                    <linearGradient id="vfx-triple-grad" x1="0%" y1="0%" x2="100%" y2="100%">
                        <stop id="vfx-stop-1" offset="0%" stop-color="${STATE.color1}" />
                        <stop id="vfx-stop-2" offset="50%" stop-color="${STATE.color2}" />
                        <stop id="vfx-stop-3" offset="100%" stop-color="${STATE.color3}" />
                    </linearGradient>
                </defs>
                <g id="vfx-shapes-layer"></g>
            </svg>
        `;
        STATE.svgOverlay = container.querySelector('#vfx-shapes-layer');
    }

    function drawAll() {
        if (!STATE.svgOverlay) return;
        getBoardState();
        STATE.svgOverlay.innerHTML = '';
        STATE.drawnShapes.forEach(shape => renderShape(shape));
    }

    function renderShape(shape) {
        // Ignora cliques simples na mesma casa
        if (shape.start === shape.end) return;

        const p1 = squareToPoint(shape.start);
        const p2 = squareToPoint(shape.end);
        const strokeColor = STATE.useGradient ? 'url(#vfx-triple-grad)' : STATE.solidColor;
        const T = parseFloat(STATE.thickness);

        const file1 = FILES.indexOf(shape.start[0]);
        const rank1 = parseInt(shape.start[1]);
        const file2 = FILES.indexOf(shape.end[0]);
        const rank2 = parseInt(shape.end[1]);

        const dx = Math.abs(file2 - file1);
        const dy = Math.abs(rank2 - rank1);
        const isKnight = (dx === 1 && dy === 2) || (dx === 2 && dy === 1);

        const baseHeadSize = Math.max(15, T * 2.5) * STATE.headSize;
        const headLength = baseHeadSize * STATE.headLength;
        const headWidth = baseHeadSize * STATE.headWidth;

        let pathD = '';
        let arrowAngle = 0;
        let finalP2 = { ...p2 };

        if (isKnight) {
            let cornerSq = (dy > dx) ? (shape.start[0] + shape.end[1]) : (shape.end[0] + shape.start[1]);
            const pc = squareToPoint(cornerSq);

            arrowAngle = Math.atan2(p2.y - pc.y, p2.x - pc.x);

            const retraction = headLength - Math.min(T/2, headLength * 0.2);
            finalP2.x = p2.x - retraction * Math.cos(arrowAngle);
            finalP2.y = p2.y - retraction * Math.sin(arrowAngle);

            pathD = `M ${p1.x} ${p1.y} L ${pc.x} ${pc.y} L ${finalP2.x} ${finalP2.y}`;
        } else {
            arrowAngle = Math.atan2(p2.y - p1.y, p2.x - p1.x);

            const retraction = headLength - Math.min(T/2, headLength * 0.2);
            finalP2.x = p2.x - retraction * Math.cos(arrowAngle);
            finalP2.y = p2.y - retraction * Math.sin(arrowAngle);

            pathD = `M ${p1.x} ${p1.y} L ${finalP2.x} ${finalP2.y}`;
        }

        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', pathD);
        path.setAttribute('class', 'vfx-shaft');

        // BLINDAGEM: Aplicando propriedades inline com !important para vencer o outro script
        path.style.setProperty('fill', 'none', 'important');
        path.style.setProperty('stroke', strokeColor, 'important');
        path.style.setProperty('stroke-width', T + 'px', 'important');
        path.style.setProperty('stroke-linecap', 'round', 'important');
        path.style.setProperty('stroke-linejoin', 'round', 'important');

        const tipX = p2.x;
        const tipY = p2.y;
        const baseX = p2.x - headLength * Math.cos(arrowAngle);
        const baseY = p2.y - headLength * Math.sin(arrowAngle);

        const leftX = baseX - (headWidth/2) * Math.sin(arrowAngle);
        const leftY = baseY + (headWidth/2) * Math.cos(arrowAngle);
        const rightX = baseX + (headWidth/2) * Math.sin(arrowAngle);
        const rightY = baseY - (headWidth/2) * Math.cos(arrowAngle);

        const head = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
        head.setAttribute('points', `${tipX},${tipY} ${leftX},${leftY} ${rightX},${rightY}`);
        head.setAttribute('class', 'vfx-head');

        // BLINDAGEM PARA A CABEÇA DA SETA
        head.style.setProperty('fill', strokeColor, 'important');
        head.style.setProperty('stroke', strokeColor, 'important');
        head.style.setProperty('stroke-width', (T * 0.1) + 'px', 'important');
        head.style.setProperty('stroke-linejoin', 'round', 'important');

        STATE.svgOverlay.appendChild(path);
        STATE.svgOverlay.appendChild(head);
    }

    // ==========================================
    // 5. EVENTOS (SEM PREVIEW EM TEMPO REAL)
    // ==========================================
    function attachBoardEvents() {
        if (!STATE.boardElement) return;

        STATE.boardElement.addEventListener('contextmenu', e => e.preventDefault(), { capture: true });

        STATE.boardElement.addEventListener('pointerdown', (e) => {
            if (e.button === 2) {
                e.preventDefault();
                getBoardState();
                const rect = STATE.boardElement.getBoundingClientRect();
                const startSq = pointToSquare(e.clientX - rect.left, e.clientY - rect.top);

                if (startSq) {
                    STATE.isDragging = true;
                    STATE.currentShape = { start: startSq, end: startSq };
                }
            } else if (e.button === 0) {
                if (STATE.drawnShapes.length > 0) {
                    STATE.drawnShapes = [];
                    drawAll();
                }
            }
        }, { capture: true });

        window.addEventListener('pointermove', (e) => {
            if (!STATE.isDragging || !STATE.currentShape || !STATE.boardElement) return;

            const rect = STATE.boardElement.getBoundingClientRect();
            const currSq = pointToSquare(e.clientX - rect.left, e.clientY - rect.top);

            if (currSq && currSq !== STATE.currentShape.end) {
                STATE.currentShape.end = currSq;
            }
        }, { capture: true });

        window.addEventListener('pointerup', (e) => {
            if (e.button === 2 && STATE.isDragging) {
                STATE.isDragging = false;

                if (STATE.currentShape && STATE.currentShape.start !== STATE.currentShape.end) {
                    const existsIdx = STATE.drawnShapes.findIndex(
                        s => s.start === STATE.currentShape.start && s.end === STATE.currentShape.end
                    );

                    if (existsIdx > -1) {
                        STATE.drawnShapes.splice(existsIdx, 1);
                    } else {
                        STATE.drawnShapes.push(STATE.currentShape);
                    }
                }
                STATE.currentShape = null;
                drawAll(); // SÓ AGORA DESENHAMOS A SETA
            }
        }, { capture: true });
    }

    // ==========================================
    // 6. UI MASTER COM SAVE E LOAD E BOTÃO FECHAR
    // ==========================================
    function initUI() {
        if (document.getElementById('vfx-master-panel')) return;

        const container = document.createElement('div');
        container.innerHTML = `
            <div id="vfx-floating-btn">🥢</div>
            <div id="vfx-master-panel" class="minimized">
                <div id="vfx-close-btn">✕</div>
                <div style="text-align: center; color: #00e5ff; font-weight: 800; font-size: 14px; margin-bottom: 5px;">VFX ARROWS 7.2</div>

                <div class="vfx-divider"></div>

                <div class="vfx-row">
                    <span class="vfx-label">Espessura</span>
                    <input type="range" id="vfx-thick-slider" class="vfx-slider" min="0.5" max="40" step="0.5" value="${STATE.thickness}">
                    <span id="vfx-thick-val" class="vfx-val-display">${STATE.thickness}</span>
                </div>

                <div class="vfx-row">
                    <span class="vfx-label">Opacidade</span>
                    <input type="range" id="vfx-op-slider" class="vfx-slider" min="0.1" max="1" step="0.05" value="${STATE.opacity}">
                    <span id="vfx-op-val" class="vfx-val-display">${STATE.opacity}</span>
                </div>

                <div class="vfx-divider"></div>
                <div style="font-size: 10px; color: #00e5ff; font-weight: bold; margin-bottom: 5px; text-transform: uppercase;">Cabeça da Seta</div>

                <div class="vfx-row">
                    <span class="vfx-label">Tamanho Global</span>
                    <input type="range" id="vfx-hsize-slider" class="vfx-slider" min="0.2" max="3" step="0.1" value="${STATE.headSize}">
                    <span id="vfx-hsize-val" class="vfx-val-display">${STATE.headSize}</span>
                </div>

                <div class="vfx-row">
                    <span class="vfx-label">Largura (Gorda)</span>
                    <input type="range" id="vfx-hwidth-slider" class="vfx-slider" min="0.2" max="3" step="0.1" value="${STATE.headWidth}">
                    <span id="vfx-hwidth-val" class="vfx-val-display">${STATE.headWidth}</span>
                </div>

                <div class="vfx-row">
                    <span class="vfx-label">Comprimento (Pontuda)</span>
                    <input type="range" id="vfx-hlength-slider" class="vfx-slider" min="0.2" max="3" step="0.1" value="${STATE.headLength}">
                    <span id="vfx-hlength-val" class="vfx-val-display">${STATE.headLength}</span>
                </div>

                <div class="vfx-divider"></div>

                <div id="vfx-mode-btn" class="vfx-toggle ${STATE.useGradient ? 'active' : ''}">${STATE.useGradient ? 'MODO: GRADIENTE' : 'MODO: COR ÚNICA'}</div>

                <div id="vfx-solid-config" class="vfx-row" style="display: ${STATE.useGradient ? 'none' : 'flex'}; margin-top: 10px;">
                    <span class="vfx-label">Cor Principal</span>
                    <input type="color" id="vfx-c-solid" class="vfx-input" value="${STATE.solidColor}">
                </div>

                <div id="vfx-grad-config" style="display: ${STATE.useGradient ? 'flex' : 'none'}; flex-direction: column; gap: 8px; margin-top: 10px;">
                    <div class="vfx-row">
                        <span class="vfx-label">Cor Início</span>
                        <input type="color" id="vfx-c1" class="vfx-input" value="${STATE.color1}">
                    </div>
                    <div class="vfx-row">
                        <span class="vfx-label">Cor Meio</span>
                        <input type="color" id="vfx-c2" class="vfx-input" value="${STATE.color2}">
                    </div>
                    <div class="vfx-row">
                        <span class="vfx-label">Cor Fim</span>
                        <input type="color" id="vfx-c3" class="vfx-input" value="${STATE.color3}">
                    </div>
                </div>
            </div>
        `;
        document.body.appendChild(container);

        const bindSlider = (id, stateKey, storeKey) => {
            const slider = document.getElementById(id);
            const valEl = document.getElementById(id.replace('-slider', '-val'));
            slider.oninput = (e) => {
                STATE[stateKey] = parseFloat(e.target.value);
                valEl.innerText = e.target.value;
                localStorage.setItem(storeKey, e.target.value);

                if (stateKey === 'opacity') {
                    const svgMain = document.getElementById('vfx-main-svg');
                    if (svgMain) svgMain.style.opacity = STATE.opacity;
                } else {
                    drawAll();
                }
            };
        };

        bindSlider('vfx-thick-slider', 'thickness', 'vfx-arrow-thick');
        bindSlider('vfx-op-slider', 'opacity', 'vfx-arrow-opacity');
        bindSlider('vfx-hsize-slider', 'headSize', 'vfx-arrow-hsize');
        bindSlider('vfx-hwidth-slider', 'headWidth', 'vfx-arrow-hwidth');
        bindSlider('vfx-hlength-slider', 'headLength', 'vfx-arrow-hlength');

        const btn = document.getElementById('vfx-floating-btn');
        const panel = document.getElementById('vfx-master-panel');
        const modeBtn = document.getElementById('vfx-mode-btn');
        const solidConf = document.getElementById('vfx-solid-config');
        const gradConf = document.getElementById('vfx-grad-config');
        const closeBtn = document.getElementById('vfx-close-btn');

        let panelOpen = false;

        btn.onclick = () => {
            panelOpen = !panelOpen;
            panel.classList.toggle('minimized', !panelOpen);
            btn.style.transform = panelOpen ? 'rotate(90deg)' : 'rotate(0deg)';
        };

        closeBtn.onclick = () => {
            panelOpen = false;
            panel.classList.add('minimized');
            btn.style.transform = 'rotate(0deg)';
        };

        modeBtn.onclick = () => {
            STATE.useGradient = !STATE.useGradient;
            modeBtn.classList.toggle('active', STATE.useGradient);
            modeBtn.innerText = STATE.useGradient ? 'MODO: GRADIENTE' : 'MODO: COR ÚNICA';
            solidConf.style.display = STATE.useGradient ? 'none' : 'flex';
            gradConf.style.display = STATE.useGradient ? 'flex' : 'none';
            localStorage.setItem('vfx-arrow-use-grad', STATE.useGradient);
            drawAll();
        };

        const updateGradientDef = () => {
            const s1 = document.getElementById('vfx-stop-1');
            const s2 = document.getElementById('vfx-stop-2');
            const s3 = document.getElementById('vfx-stop-3');
            if (s1 && s2 && s3) {
                s1.setAttribute('stop-color', STATE.color1);
                s2.setAttribute('stop-color', STATE.color2);
                s3.setAttribute('stop-color', STATE.color3);
            }
        };

        document.getElementById('vfx-c-solid').oninput = (e) => {
            STATE.solidColor = e.target.value;
            localStorage.setItem('vfx-arrow-solid', STATE.solidColor);
            drawAll();
        };

        ['c1', 'c2', 'c3'].forEach((id, idx) => {
            document.getElementById(`vfx-${id}`).oninput = (e) => {
                STATE[`color${idx+1}`] = e.target.value;
                localStorage.setItem(`vfx-arrow-${id}`, e.target.value);
                updateGradientDef();
            };
        });
    }

    // ==========================================
    // 7. INICIALIZAÇÃO
    // ==========================================
    function initSystem() {
        const board = document.querySelector('wc-chess-board') || document.querySelector('.cg-board');
        if (board && !STATE.boardElement) {
            STATE.boardElement = board;
            setupSVG();
            attachBoardEvents();
            initUI();

            const resizeObserver = new ResizeObserver(() => requestAnimationFrame(drawAll));
            resizeObserver.observe(board);

            const classObserver = new MutationObserver(() => drawAll());
            classObserver.observe(board, { attributes: true, attributeFilter: ['class'] });
        }
    }

    const bootInterval = setInterval(() => {
        initSystem();
        if (STATE.boardElement) clearInterval(bootInterval);
    }, 500);

    document.addEventListener('DOMContentLoaded', initSystem);

})();