Affiche une bulle de tchat au-dessus des personnages
// ==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();
})();