ChatBulle

Affiche une bulle de tchat au-dessus des personnages

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 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         ChatBulle
// @namespace    InGame
// @version      1.4.0
// @author       JD Asalia & Laïn
// @description  Affiche une bulle de tchat au-dessus des personnages
// @match        *://www.dreadcast.net/Main*
// @match        *://www.dreadcast.eu/Main*
// @grant        none
// @license      MIT
// ==/UserScript==

$('<style>').text(`
    .chatbulle-highlight {
        outline: 2px solid rgba(88, 220, 249, 0.8);
        outline-offset: 2px;
        border-radius: 4px;
        box-shadow: 0 0 8px rgba(88, 220, 249, 0.8);
        transition: box-shadow 0.3s ease-in-out;
    }

    .chat_bubble {
        position: absolute;
        background: rgba(13, 19, 32, 0.94);
        border: 1px solid var(--bubble-color, #58dcf9);
        border-radius: 12px;
        font-size: 12px;
        line-height: 14px;
        max-width: 180px;
        min-width: 60px;
        color: #58dcf9;
        text-align: center;
        white-space: normal;
        word-wrap: break-word;
        pointer-events: none;
        padding: 8px 12px;
        opacity: 0;
        z-index: 150000;
        box-shadow: 0 0 18px var(--bubble-glow, rgba(88, 220, 249, 0.25));
        transition: left 0.1s ease-out, top 0.1s ease-out;
        transform: translate(-50%, -100%);
    }

    .chat_bubble::after {
        content: "";
        position: absolute;
        left: 50%;
        bottom: -7px;
        width: 12px;
        height: 12px;
        background: rgba(13, 19, 32, 0.94);
        border-right: 1px solid var(--bubble-color, #58dcf9);
        border-bottom: 1px solid var(--bubble-color, #58dcf9);
        transform: translateX(-50%) rotate(45deg);
    }

    #chatbubble-config-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.7);
        z-index: 200000;
        display: none;
        justify-content: center;
        align-items: center;
    }

    #chatbubble-config-panel {
        background: linear-gradient(180deg, #111a2c 0%, #0d1320 100%);
        border: 1px solid #1d2a42;
        border-top: 2px solid #58dcf9;
        border-radius: 8px;
        padding: 20px;
        width: 400px;
        max-width: 90%;
        color: #c9d4e3;
        box-shadow: 0 0 20px rgba(88, 220, 249, 0.2);
    }

    #chatbubble-config-panel h2 {
        margin-top: 0;
        text-align: center;
        color: #58dcf9;
        font-size: 18px;
        border-bottom: 1px solid #1d2a42;
        padding-bottom: 10px;
    }

    .config-option {
        margin: 20px 0;
    }

    .config-option label {
        display: block;
        margin-bottom: 8px;
        font-weight: bold;
        color: #58dcf9;
    }

    .config-option input[type="range"] {
        width: 100%;
        margin: 10px 0;
        accent-color: #58dcf9;
    }

    .config-option .value-display {
        display: inline-block;
        margin-left: 10px;
        color: #ffffff;
        font-weight: bold;
    }

    .config-buttons {
        display: flex;
        justify-content: space-between;
        margin-top: 25px;
        gap: 10px;
    }

    .config-buttons button {
        flex: 1;
        padding: 10px;
        border: 1px solid #1d2a42;
        background: rgba(88, 220, 249, 0.06);
        color: #c9d4e3;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        transition: border-color 0.2s, background 0.2s, color 0.2s;
    }

    .config-buttons button:hover {
        border-color: #58dcf9;
        background: rgba(88, 220, 249, 0.15);
        color: #58dcf9;
    }
    .whisper-sender {
    color: #707070 !important; /* gris foncé */
}
.shout-sender {
    color: #ff5555 !important;
}
    .bubble-preview {
        background: rgba(13, 19, 32, 0.94);
        border: 1px solid #58dcf9;
        border-radius: 12px;
        padding: 8px 12px;
        text-align: center;
        margin: 15px auto;
        max-width: 180px;
        color: #58dcf9;
        box-shadow: 0 0 18px rgba(88, 220, 249, 0.25);
    }
    `).appendTo('head');

const CHAT_CONTAINER_SELECTOR = '#chatContent';
const CHARACTERS_SELECTOR = '.personnages';
const CROSS_SELECTOR = '#croix_position';
const MSG_SELECTOR = '.msg';
const PSEUDO_SELECTOR = 'span.linkable, em';
const BUBBLE_OFFSET_LEFT = 0;
const BUBBLE_OFFSET_TOP = 10;
const BUBBLE_PADDING = '6px 10px';
const BUBBLE_BG_COLOR = 'rgba(13, 19, 32, 0.94)';
const BUBBLE_BORDER_RADIUS = '12px';
const BUBBLE_FONT_SIZE = '12px';
const BUBBLE_LINE_HEIGHT = '14px';
const BUBBLE_MAX_WIDTH = '180px';
const BUBBLE_MIN_WIDTH = '60px';
const BUBBLE_Z_INDEX = 100000;
const BUBBLE_TEXT_COLOR = '#58dcf9';
const BUBBLE_ANIMATION_DURATION = 300;
const BUBBLE_FADE_IN_DURATION = 300;
const BUBBLE_DISPLAY_DURATION = 10000;
const BUBBLE_FADE_OUT_DURATION = 300;
const BUBBLE_SPACING = 0;
const POSITION_UPDATE_INTERVAL = 100;
const POSITION_MARGIN = 15;
const BUBBLE_GAP = 6; // espace minimal garanti entre deux bulles
const HIGHLIGHT_DURATION = 3000;

(function() {
    'use strict';
    console.log('[ChatBulle Dynamic] Script chargé ✅');

    const activeBubbles = {};
    let bubbleSeq = 0;      // ordre de création stable pour l'empilement
    let layoutLoop = null;  // boucle unique de repositionnement global

    const DEFAULT_SETTINGS = {
        fontSize: 12,
        backgroundOpacity: 0.2,
        displayDuration: 5000
    };

    function loadSettings() {
        const saved = localStorage.getItem('chatbubble_settings');
        if (saved) {
            try {
                return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
            } catch (e) {
                console.warn('[ChatBulle] Erreur de chargement des paramètres:', e);
                return DEFAULT_SETTINGS;
            }
        }
        return DEFAULT_SETTINGS;
    }

    function saveSettings(settings) {
        localStorage.setItem('chatbubble_settings', JSON.stringify(settings));
        applySettings(settings);
    }

    function applySettings(settings) {
        let styleElement = document.getElementById('chatbubble-dynamic-style');
        if (!styleElement) {
            styleElement = document.createElement('style');
            styleElement.id = 'chatbubble-dynamic-style';
            document.head.appendChild(styleElement);
        }

        styleElement.textContent = `
                .chat_bubble {
                    font-size: ${settings.fontSize}px !important;
                    line-height: ${settings.fontSize + 2}px !important;
                    background: rgba(13, 19, 32, ${settings.backgroundOpacity}) !important;
                }
                .chat_bubble::after {
                    background: rgba(13, 19, 32, ${settings.backgroundOpacity}) !important;
                }
            `;
    }

    let userSettings = loadSettings();
    applySettings(userSettings);

    function showConfigPanel() {
        let overlay = document.getElementById('chatbubble-config-overlay');
        if (!overlay) {
            overlay = document.createElement('div');
            overlay.id = 'chatbubble-config-overlay';

            const panel = document.createElement('div');
            panel.id = 'chatbubble-config-panel';
            panel.innerHTML = `
                    <h2>⚙️ Configuration Bulle Chat</h2>

                    <div class="config-option">
                        <label>Taille du texte: <span class="value-display" id="fontSize-value">${userSettings.fontSize}px</span></label>
                        <input type="range" id="fontSize-slider" min="8" max="20" value="${userSettings.fontSize}" step="1">
                    </div>

                    <div class="config-option">
                        <label>Opacité du fond: <span class="value-display" id="bgOpacity-value">${Math.round(userSettings.backgroundOpacity * 100)}%</span></label>
                        <input type="range" id="bgOpacity-slider" min="0" max="100" value="${userSettings.backgroundOpacity * 100}" step="5">
                    </div>

                    <div class="config-option">
                        <label>Durée d'affichage: <span class="value-display" id="duration-value">${userSettings.displayDuration / 1000}s</span></label>
                        <input type="range" id="duration-slider" min="1" max="20" value="${userSettings.displayDuration / 1000}" step="1">
                    </div>

                    <div class="config-option">
                        <label>Aperçu:</label>
                        <div class="bubble-preview" id="bubble-preview">
                            <span style="color: #58dcf9;">Exemple: Message de test</span>
                        </div>
                    </div>

                    <div class="config-buttons">
                        <button id="config-reset">Réinitialiser</button>
                        <button id="config-close">Fermer</button>
                    </div>
                `;

            overlay.appendChild(panel);
            document.body.appendChild(overlay);

            const fontSizeSlider = document.getElementById('fontSize-slider');
            const fontSizeValue = document.getElementById('fontSize-value');
            const bgOpacitySlider = document.getElementById('bgOpacity-slider');
            const bgOpacityValue = document.getElementById('bgOpacity-value');
            const durationSlider = document.getElementById('duration-slider');
            const durationValue = document.getElementById('duration-value');
            const preview = document.getElementById('bubble-preview');

            fontSizeSlider.addEventListener('input', (e) => {
                const value = parseInt(e.target.value);
                fontSizeValue.textContent = value + 'px';
                preview.style.fontSize = value + 'px';
                preview.style.lineHeight = (value + 2) + 'px';

                userSettings.fontSize = value;
                saveSettings(userSettings);
            });

            bgOpacitySlider.addEventListener('input', (e) => {
                const value = parseInt(e.target.value) / 100;
                bgOpacityValue.textContent = Math.round(value * 100) + '%';
                preview.style.background = `rgba(13, 19, 32, ${value})`;

                userSettings.backgroundOpacity = value;
                saveSettings(userSettings);
            });

            durationSlider.addEventListener('input', (e) => {
                const value = parseInt(e.target.value) * 1000;
                durationValue.textContent = (value / 1000) + 's';

                userSettings.displayDuration = value;
                saveSettings(userSettings);
            });

            document.getElementById('config-reset').addEventListener('click', () => {
                userSettings = { ...DEFAULT_SETTINGS };
                saveSettings(userSettings);

                fontSizeSlider.value = DEFAULT_SETTINGS.fontSize;
                fontSizeValue.textContent = DEFAULT_SETTINGS.fontSize + 'px';
                bgOpacitySlider.value = DEFAULT_SETTINGS.backgroundOpacity * 100;
                bgOpacityValue.textContent = Math.round(DEFAULT_SETTINGS.backgroundOpacity * 100) + '%';
                durationSlider.value = DEFAULT_SETTINGS.displayDuration / 1000;
                durationValue.textContent = (DEFAULT_SETTINGS.displayDuration / 1000) + 's';

                preview.style.fontSize = DEFAULT_SETTINGS.fontSize + 'px';
                preview.style.lineHeight = (DEFAULT_SETTINGS.fontSize + 2) + 'px';
                preview.style.backgroundColor = `rgba(0, 0, 0, ${DEFAULT_SETTINGS.backgroundOpacity})`;
            });

            document.getElementById('config-close').addEventListener('click', () => {
                overlay.style.display = 'none';
            });

            overlay.addEventListener('click', (e) => {
                if (e.target === overlay) {
                    overlay.style.display = 'none';
                }
            });

            preview.style.fontSize = userSettings.fontSize + 'px';
            preview.style.lineHeight = (userSettings.fontSize + 2) + 'px';
            preview.style.backgroundColor = `rgba(0, 0, 0, ${userSettings.backgroundOpacity})`;
        }

        overlay.style.display = 'flex';
    }

    function addConfigMenuItem() {
        const checkInterval = setInterval(() => {
            const parametresMenu = $('.parametres.couleur5.right ul');
            if (parametresMenu.length > 0) {
                clearInterval(checkInterval);

                if (!parametresMenu.find('.chatbubble-config-menu').length) {
                    const menuItem = $('<li class="link couleur2 chatbubble-config-menu">Bulle Chat</li>');
                    menuItem.on('click', (e) => {
                        e.stopPropagation();
                        showConfigPanel();
                    });

                    const configChatItem = parametresMenu.find('li:contains("Configuration du Chat")');
                    if (configChatItem.length > 0) {
                        configChatItem.after(menuItem);
                    } else {
                        parametresMenu.append(menuItem);
                    }

                    console.log('[ChatBulle] Menu de configuration ajouté ✅');
                }
            }
        }, 1000);

        setTimeout(() => clearInterval(checkInterval), 30000);
    }

    // Renvoie le point d'ancrage (centre horizontal + haut) de l'icône du
    // personnage qui a parlé, ou de la croix de position en repli.
    function resolveAnchor(pseudo) {
        const $info = $(`${CHARACTERS_SELECTOR} .info_a_afficher`).filter(function() {
            return $(this).html().toLowerCase()
                .split('<br>')
                .map(s => s.trim())
                .includes(pseudo.toLowerCase());
        });

        let $target;

        if ($info.length) {
            const $iconPerso = $info.closest('.icon_perso');
            $target = $iconPerso.find('.le_icon_perso');
            if (!$target.length) $target = $iconPerso;
        } else {
            $target = $(CROSS_SELECTOR);
        }

        if (!$target || !$target.length) return null;

        const rect = $target[0].getBoundingClientRect();
        if (!rect.width && !rect.height) return null;

        const scrollX = window.scrollX || window.pageXOffset || 0;
        const scrollY = window.scrollY || window.pageYOffset || 0;
        return {
            cx:  Math.round(rect.left + rect.width  / 2 + scrollX),
            top: Math.round(rect.top                    + scrollY)
        };
    }

    // Vrai si les deux rectangles se chevauchent en tenant compte d'un écart minimal.
    function rectsOverlap(a, b, gap = 0) {
        return !(a.right  + gap <= b.left ||
                 a.left   - gap >= b.right ||
                 a.bottom + gap <= b.top ||
                 a.top    - gap >= b.bottom);
    }

    // Repositionne TOUTES les bulles actives en une seule passe déterministe.
    // Chaque bulle vise le point au-dessus de la tête de son personnage ; si elle
    // chevauche une bulle déjà placée, on la remonte au-dessus. Résultat : aucune
    // bulle n'en touche une autre, même si plusieurs joueurs parlent en même temps.
    function layoutAllBubbles() {
        const items = [];

        for (const pseudo in activeBubbles) {
            const anchor = resolveAnchor(pseudo);
            if (!anchor) continue;

            for (const $b of activeBubbles[pseudo]) {
                items.push({
                    $b,
                    anchor,
                    w: $b.outerWidth() || 90,
                    h: $b.outerHeight() || 30,
                    seq: $b.data('seq') || 0
                });
            }
        }

        if (!items.length) return;

        // Ordre stable : de gauche à droite, puis de la plus récente à la plus ancienne
        // (la plus récente se place au plus près de la tête, les anciennes sont poussées
        // vers le haut — comme un chat qui défile, le dernier message en bas).
        items.sort((a, b) => (a.anchor.cx - b.anchor.cx) || (b.seq - a.seq));

        const placed = [];

        for (const it of items) {
            const cx = it.anchor.cx;
            let by = it.anchor.top - POSITION_MARGIN; // bord BAS de la bulle (ancre transform -100%)

            const makeRect = () => ({
                left:   cx - it.w / 2,
                right:  cx + it.w / 2,
                top:    by - it.h,
                bottom: by
            });

            let rect = makeRect();
            let moved = true;
            let guard = 0;

            // Tant qu'on chevauche une bulle déjà placée, on remonte juste au-dessus d'elle.
            // Le garde-fou évite toute boucle infinie en cas de configuration pathologique.
            while (moved && guard++ < 200) {
                moved = false;
                for (const p of placed) {
                    if (rectsOverlap(rect, p, BUBBLE_GAP)) {
                        by = p.top - BUBBLE_GAP;
                        rect = makeRect();
                        moved = true;
                    }
                }
            }

            placed.push(rect);
            it.$b.css({ left: cx + 'px', top: by + 'px' });
        }
    }

    // Démarre l'unique boucle de repositionnement (idempotent).
    function startLayoutLoop() {
        if (layoutLoop) return;
        layoutLoop = setInterval(layoutAllBubbles, POSITION_UPDATE_INTERVAL);
    }

    function showChatBubble(pseudo, messageHTML, isWhisper, isShout, pseudoColor) {

        let $target = null;

        const $info = $(`${CHARACTERS_SELECTOR} .info_a_afficher`)
        .filter(function() {
            return $(this).html().toLowerCase()
                .split('<br>')
                .map(s=>s.trim())
                .includes(pseudo.toLowerCase());
        });

        if ($info.length) {
            const $icon = $info.closest('.icon_perso');
            $target = $icon.find('.le_icon_perso');
            if (!$target.length) $target = $icon;

            $target.addClass('chatbulle-highlight');
            setTimeout(() => $target.removeClass('chatbulle-highlight'), HIGHLIGHT_DURATION);
        } else {
            $target = $(CROSS_SELECTOR);
            if($target.length){
                $target.addClass('chatbulle-highlight');
                setTimeout(()=> $target.removeClass('chatbulle-highlight'), HIGHLIGHT_DURATION);
            }
        }

        if(!$target || !$target.length) return;

        const $bubble = $('<div class="chat_bubble"></div>').html(messageHTML);
        if(isWhisper) $bubble.addClass('whisper');
        if(isShout)   $bubble.addClass('shout');
        if (pseudoColor) {
            const glow = pseudoColor.replace('rgb(', 'rgba(').replace(')', ', 0.3)');
            $bubble[0].style.setProperty('--bubble-color', pseudoColor);
            $bubble[0].style.setProperty('--bubble-glow', glow);
        }

        $bubble.css({ zIndex:BUBBLE_Z_INDEX });
        $bubble.data('pseudo', pseudo);
        $bubble.data('seq', bubbleSeq++);

        $('body').append($bubble);

        if(!activeBubbles[pseudo]) activeBubbles[pseudo] = [];
        activeBubbles[pseudo].unshift($bubble);

        // Placement initial immédiat (évite un flash en 0,0) puis suivi par la boucle globale.
        layoutAllBubbles();
        startLayoutLoop();

        $bubble.animate({opacity:1}, BUBBLE_FADE_IN_DURATION);

        setTimeout(()=>{
            $bubble.animate({opacity:0}, BUBBLE_FADE_OUT_DURATION, function(){
                const arr = activeBubbles[pseudo];
                if(arr){
                    const idx = arr.indexOf($bubble);
                    if(idx!==-1) arr.splice(idx,1);
                    if(arr.length===0) delete activeBubbles[pseudo];
                }
                $(this).remove();
            });
        }, userSettings.displayDuration);
    }

function colorizeMessage($node) {
    const $clone = $node.clone();
    $clone.find('.moment').remove();

    const isWhisper = $node.hasClass('couleur5');
    const isShout   = $node.hasClass('couleur_rouge');

    $clone.find(PSEUDO_SELECTOR).each(function() {
        const $pseudo = $(this);
        const next = $pseudo[0].nextSibling;
        let colon = '';

        // On extrait le ":" qui suit le pseudo
        if (next && next.nodeType === Node.TEXT_NODE) {
            const match = next.textContent.match(/^(:\s*)/);
            if (match) {
                colon = match[1];
                next.textContent = next.textContent.slice(match[1].length);
            }
        }

        if (isWhisper) {
            $pseudo.contents().filter(function() { return this.nodeType === Node.TEXT_NODE; })
                .wrap('<span class="whisper-sender"></span>');
            if (colon) {
                $pseudo.find('.whisper-sender').append(colon);
            }
        }
        else if (isShout) {
            $pseudo.contents().filter(function() { return this.nodeType === Node.TEXT_NODE; })
                .wrap('<span class="shout-sender"></span>');
            if (colon) {
                $pseudo.find('.shout-sender').append(colon);
            }
        }
        else
        {
            $pseudo.contents().filter(function() { return this.nodeType === Node.TEXT_NODE; })
                .wrap('<span class="' + $pseudo.attr('class') + '"></span>');
            if (colon) {
                $pseudo.append(colon);
            }
        }
    });

    return { html: $clone.html().trim(), isWhisper: isWhisper, isShout: isShout };
}

function observeChat() {
    const chat = document.querySelector(CHAT_CONTAINER_SELECTOR);
    if(!chat) return;

    console.log('[ChatBulle] Observer prêt.');

    const obs = new MutationObserver(muts=>{
        for(const m of muts){
            for(const n of m.addedNodes){
                if(n.nodeType!==1 || !n.classList.contains('msg')) continue;

                const $msg = $(n);

                let pseudo = '';
                let contentHTML = '';
                let isWhisper=false;
                let isShout=false;

                const em = $msg.find('em');
                const link = $msg.find('span.linkable');

                if(link.length){
                    pseudo = link.text().trim();
                }
                else if(em.length){
                    pseudo = em.text().trim().split(' ')[0];
                }
                else {
                    pseudo = $msg.text().split(':')[0].trim();
                }

                if(pseudo.includes('@')) pseudo = pseudo.split('@')[0].trim();

                const c = colorizeMessage($msg);
                contentHTML = c.html;
                isWhisper   = c.isWhisper;
                isShout     = c.isShout;

                let pseudoColor = null;
                const pseudoSpan = $msg.find('span.linkable');
                if (pseudoSpan.length) {
                    pseudoColor = window.getComputedStyle(pseudoSpan[0]).color;
                } else if (em.length) {
                    pseudoColor = window.getComputedStyle(em[0]).color;
                }
                if (isWhisper) pseudoColor = 'rgb(112, 112, 112)';
                if (isShout)   pseudoColor = 'rgb(255, 85, 85)';

                showChatBubble(pseudo, contentHTML, isWhisper, isShout, pseudoColor);
            }
        }
    });

    obs.observe(chat, {childList:true});
}
    function waitForChat() {
        const chatOK = $(CHAT_CONTAINER_SELECTOR).length > 0;
        const mapOK = $(CHARACTERS_SELECTOR).length > 0;
        if (chatOK && mapOK) {
            observeChat();
            addConfigMenuItem();
        } else {
            setTimeout(waitForChat, 1000);
        }
    }

    waitForChat();
})();