
롤20의 기본 설정인 ㄱㄴㄷ 오름차순대신,
[저널탭 캐릭터 순서대로] ▶ [채팅 화자 목록]을 배열하는 스크립트입니다.
롤20 무료 유저도 사용할 수 있으며 시트가 많을 때 플레이어 캐릭터와 혼동하지 않게 NPC끼리 모아놓는 용도로 사용합니다.
이 스크립트는 크롬 확장 프로그램인 "Tampermonkey 템퍼몽키"를 사용합니다.
템퍼몽키 사용법: https://trpgdata.tistory.com/49
Tampermonkey 템퍼몽키 설치부터 사용법
Tampermonkey(템퍼몽키)란 무엇인가요?템퍼몽키는 구글 크롬, 엣지, 웨일 등 브라우저에서 사용할 수 있는 '유저스크립트(Userscript) 관리자' 확장 프로그램입니다.웹사이트의 디자인이나 기능을 개인
trpgdata.tistory.com
하단의 스크립트를 복사해서 사용하거나
아래의 스크립트 설치 페이지 링크에서 [설치]하면 됩니다.
https://github.com/…/userscript/r20SpeakAs.user.js
// ==UserScript==
// @name Roll20 화자 목록 저널 순서 실시간 동기화
// @homepageURL https://trpgdata.tistory.com/48
// @namespace http://tampermonkey.net/
// @version 1.4.2
// @description 채팅창 화자 드롭다운을 저널의 캐릭터 정렬 순서대로 정렬 / 전체공개 #6b92c1, 특정 유저 #aaaaaa으로 표시
// @match https://app.roll20.net/editor*
// @grant none
// @run-at document-idle
// @updateURL https://github.com/orzotea/TRPG_tools/raw/refs/heads/main/userscript/r20SpeakAs.user.js
// @downloadURL https://github.com/orzotea/TRPG_tools/raw/refs/heads/main/userscript/r20SpeakAs.user.js
// ==/UserScript==
(function () {
'use strict';
function matchesRGB(colorStr, r, g, b) {
if (!colorStr) return false;
const m = colorStr.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/i);
if (!m) return false;
return +m[1] === r && +m[2] === g && +m[3] === b;
}
function hexToRGB(hex) {
const n = parseInt(hex.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function matchesHex(colorStr, hex) {
if (!colorStr) return false;
const normalHex = hex.toLowerCase();
const str = colorStr.toLowerCase().replace(/\s/g, '');
if (str === normalHex) return true;
const [r, g, b] = hexToRGB(normalHex);
return matchesRGB(colorStr, r, g, b);
}
function getCharacterPermissionMap() {
const permMap = new Map();
const ALL_COLOR = '#3e88e7';
const [AR, AG, AB] = hexToRGB(ALL_COLOR);
document.querySelectorAll('#journal .character').forEach(item => {
const nameEl = item.querySelector('.namecontainer') || item.querySelector('.name');
if (!nameEl) return;
const name = nameEl.textContent.trim();
const dotsContainer = item.querySelector('.playerdots');
const dot = dotsContainer
? dotsContainer.querySelector('.playerdot')
: item.querySelector('.playerdot');
if (!dot) {
permMap.set(name, 'gm');
return;
}
// inline style: color → backgroundColor → background 순으로 확인
const inlineColor = dot.style.color
|| dot.style.backgroundColor
|| dot.style.background
|| '';
// computed style도 동일 순서로 확인
const cs = window.getComputedStyle(dot);
const computedColor = cs.color || cs.backgroundColor || '';
const isAll = matchesHex(inlineColor, ALL_COLOR)
|| matchesRGB(computedColor, AR, AG, AB);
permMap.set(name, isAll ? 'all' : 'specific');
});
return permMap;
}
function findJournalIndex(journalOrder, optName) {
let idx = journalOrder.findIndex(n => n === optName);
if (idx !== -1) return idx;
idx = journalOrder.findIndex(n => n.includes(optName));
if (idx !== -1) return idx;
return journalOrder.findIndex(n => optName.includes(n));
}
function findPermByName(permMap, optName) {
if (permMap.has(optName)) return permMap.get(optName);
for (const [jName, p] of permMap) {
if (jName.includes(optName)) return p;
}
for (const [jName, p] of permMap) {
if (optName.includes(jName)) return p;
}
return null;
}
let isSorting = false;
function sortSpeakingAs() {
if (isSorting) return;
isSorting = true;
try {
_doSort();
} finally {
isSorting = false;
}
}
function _doSort() {
const select = document.getElementById('speakingas');
if (!select) return;
const journalOrder = [];
document.querySelectorAll('#journal .character').forEach(item => {
const nameEl = item.querySelector('.namecontainer') || item.querySelector('.name');
if (nameEl) journalOrder.push(nameEl.textContent.trim());
});
const permMap = getCharacterPermissionMap();
const options = Array.from(select.options);
const currentValue = select.value;
if (journalOrder.length > 0) {
options.sort((a, b) => {
const nameA = a.textContent.trim();
const nameB = b.textContent.trim();
const isGMA = nameA.includes('(GM)');
const isGMB = nameB.includes('(GM)');
if (isGMA && !isGMB) return -1;
if (!isGMA && isGMB) return 1;
const idxA = findJournalIndex(journalOrder, nameA);
const idxB = findJournalIndex(journalOrder, nameB);
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
if (idxA !== -1) return -1;
if (idxB !== -1) return 1;
return 0;
});
}
while (select.firstChild) select.removeChild(select.firstChild);
options.forEach(opt => {
const perm = findPermByName(permMap, opt.textContent.trim());
if (perm === 'all') {
// 전체 공개 권한 컬러_연파랑
opt.style.color = '#6b92c1';
} else if (perm === 'specific') {
// 특정 플레이어 전용 컬러_연회색
opt.style.color = '#aaaaaa';
} else {
opt.style.color = '';
}
select.appendChild(opt);
});
if (select.value !== currentValue) {
select.value = currentValue;
}
}
function initObserver() {
const journal = document.getElementById('journal');
if (journal) {
new MutationObserver(() => sortSpeakingAs())
.observe(journal, { childList: true, subtree: true });
function attachDotObservers() {
document.querySelectorAll('#journal .character .playerdot').forEach(dot => {
if (dot._r20obs) return;
const obs = new MutationObserver(() => sortSpeakingAs());
obs.observe(dot, { attributes: true, attributeFilter: ['style'] });
dot._r20obs = obs;
});
}
attachDotObservers();
new MutationObserver(() => attachDotObservers())
.observe(journal, { childList: true, subtree: true });
} //
const chatRoot = document.getElementById('chatvariables')
|| document.getElementById('chat-panel')
|| document.body;
new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.id === 'speakingas' || node.querySelector?.('#speakingas')) {
sortSpeakingAs();
return;
}
}
}
}).observe(chatRoot, { childList: true, subtree: true });
document.addEventListener('mousedown', (e) => {
if (e.target?.id === 'speakingas') sortSpeakingAs();
}, true);
sortSpeakingAs();
} // ← initObserver 함수를 닫음
let waited = 0;
const MAX_WAIT_MS = 120_000;
const POLL_MS = 1_000;
const checkExist = setInterval(() => {
waited += POLL_MS;
if (document.getElementById('speakingas') && document.getElementById('journal')) {
clearInterval(checkExist);
initObserver();
return;
}
if (waited >= MAX_WAIT_MS) {
clearInterval(checkExist);
console.warn('[r20SpeakAs] speakingas 또는 journal을 2분 내에 찾지 못해 종료합니다.');
}
}, POLL_MS);
})();
v1.4.2 (2026-06-26) 오타 수정
v1.4.1 (2026-06-16) 안정화
v1.4 (2026-06-15) 캐릭터 권한마다 드롭다운 텍스트 컬러 변경
v1.3 (2026-06-10) 스크립트 자동 업데이트 주소 추가
v1.2 (2026-05-31) 스크립트 공개
'Roll20 세팅 > 무료 스크립트' 카테고리의 다른 글
| Tampermonkey 템퍼몽키 설치부터 사용법 (0) | 2026.05.31 |
|---|
