Files
server/doc/marketplace-scripts/emote-combo/source.js

300 lines
8.7 KiB
JavaScript

const COMBO_MESSAGE_REQUIREMENT = 3;
const COMBO_IDLE_TIMEOUT_SECONDS = 6;
const COMBO_PERSIST_SECONDS = 4;
const SHAKE_DURATION_MS = 350;
const SHAKE_MAGNITUDE = 6;
const ICON_SIZE = 48;
const PADDING = 16;
const FONT = "24px 'Helvetica Neue', Arial, sans-serif";
const LABEL_COLOR = "#ffffff";
function ensureState(state) {
if (!state.emoteStats) {
state.emoteStats = new Map();
}
if (!state.emoteCache) {
state.emoteCache = new Map();
}
if (!state.messageIndex) {
state.messageIndex = 0;
}
if (!state.lastProcessedTimestamp) {
state.lastProcessedTimestamp = 0;
}
return state;
}
function buildCatalogMap(emoteCatalog) {
const map = new Map();
if (!Array.isArray(emoteCatalog)) {
return map;
}
emoteCatalog.forEach((emote) => {
const name = emote?.name ? String(emote.name) : "";
if (!name) {
return;
}
map.set(name, {
id: emote?.id ? String(emote.id) : name,
name,
url: emote?.url || null,
});
});
return map;
}
function getEmoteBitmap(url, state) {
if (!url) {
return null;
}
const cache = state.emoteCache;
const existing = cache.get(url);
if (existing?.bitmap) {
return existing.bitmap;
}
if (existing?.loading) {
return null;
}
const entry = { loading: true, bitmap: null };
cache.set(url, entry);
fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load emote");
}
return response.blob();
})
.then((blob) => createImageBitmap(blob))
.then((bitmap) => {
entry.bitmap = bitmap;
entry.loading = false;
})
.catch(() => {
cache.delete(url);
});
return null;
}
function addEmoteOccurrence(found, emoteKey, name, url, count = 1) {
if (!emoteKey) {
return;
}
const existing = found.get(emoteKey) || { key: emoteKey, name, url, count: 0 };
existing.name = name || existing.name;
existing.url = url || existing.url;
existing.count += count;
found.set(emoteKey, existing);
}
function extractEmotes(message, catalogMap) {
const found = new Map();
const fragments = Array.isArray(message?.fragments) ? message.fragments : [];
let sawEmoteFragment = false;
fragments.forEach((fragment) => {
if (fragment?.type === "emote") {
sawEmoteFragment = true;
const key = String(fragment?.id || fragment?.name || fragment?.text || fragment?.url || "");
addEmoteOccurrence(
found,
key,
fragment?.name || fragment?.text || key,
fragment?.url || null,
1,
);
}
});
if (!sawEmoteFragment) {
const text = message?.message || fragments.map((fragment) => fragment?.text || "").join("");
if (text && catalogMap.size) {
text.split(/\s+/).forEach((token) => {
if (!token) {
return;
}
const catalogEntry = catalogMap.get(token);
if (!catalogEntry) {
return;
}
addEmoteOccurrence(found, catalogEntry.id, catalogEntry.name, catalogEntry.url, 1);
});
}
}
return found;
}
function updateComboForMessage(message, state, catalogMap) {
const emotesInMessage = extractEmotes(message, catalogMap);
state.messageIndex += 1;
const now = message?.timestamp || Date.now();
if (!emotesInMessage.size) {
if (state.previousMessageKeys) {
state.previousMessageKeys.forEach((key) => {
const stats = state.emoteStats.get(key);
if (stats && stats.lastMessageIndex === state.messageIndex - 1) {
stats.streak = 0;
state.emoteStats.set(key, stats);
}
});
}
state.previousMessageKeys = new Set();
return;
}
const messageKeys = new Set(emotesInMessage.keys());
emotesInMessage.forEach((emote) => {
const key = emote.key;
const stats = state.emoteStats.get(key) || {
key,
name: emote.name,
url: emote.url,
streak: 0,
lastMessageIndex: 0,
comboCount: 0,
lastSeenTime: 0,
};
stats.name = emote.name || stats.name;
stats.url = emote.url || stats.url;
if (stats.lastMessageIndex === state.messageIndex - 1) {
stats.streak += 1;
} else {
stats.streak = 1;
stats.comboCount = 0;
}
stats.lastMessageIndex = state.messageIndex;
stats.lastSeenTime = now;
if (stats.streak >= COMBO_MESSAGE_REQUIREMENT) {
if (!state.activeComboKey || state.activeComboKey === key) {
if (state.activeComboKey !== key) {
stats.comboCount = stats.streak;
state.activeComboKey = key;
} else {
stats.comboCount += 1;
}
state.activeComboLastSeen = now;
state.lastCombo = {
key,
name: stats.name,
url: stats.url,
count: stats.comboCount,
};
state.shakeUntil = now + SHAKE_DURATION_MS;
}
}
state.emoteStats.set(key, stats);
});
if (state.previousMessageKeys) {
state.previousMessageKeys.forEach((key) => {
if (messageKeys.has(key)) {
return;
}
const stats = state.emoteStats.get(key);
if (stats && stats.lastMessageIndex === state.messageIndex - 1) {
stats.streak = 0;
state.emoteStats.set(key, stats);
}
});
}
state.previousMessageKeys = messageKeys;
}
function processNewMessages(messages, state, catalogMap) {
const freshMessages = messages.filter((message) => {
const timestamp = message?.timestamp || 0;
return timestamp > state.lastProcessedTimestamp;
});
freshMessages.forEach((message) => {
updateComboForMessage(message, state, catalogMap);
state.lastProcessedTimestamp = Math.max(state.lastProcessedTimestamp, message.timestamp || 0);
});
}
function resolveDisplayedCombo(state, now) {
if (state.activeComboKey) {
const stats = state.emoteStats.get(state.activeComboKey);
if (stats && now - stats.lastSeenTime <= COMBO_IDLE_TIMEOUT_SECONDS * 1000) {
return {
name: stats.name,
url: stats.url,
count: stats.comboCount,
active: true,
};
}
state.activeComboKey = null;
state.comboEndedAt = now;
if (state.lastCombo) {
state.lastCombo = { ...state.lastCombo };
}
}
if (state.comboEndedAt && now - state.comboEndedAt <= COMBO_PERSIST_SECONDS * 1000) {
return state.lastCombo ? { ...state.lastCombo, active: false } : null;
}
return null;
}
function drawCombo(context, state, combo, now) {
const { ctx, width, height } = context;
if (!ctx || !combo) {
return;
}
ctx.clearRect(0, 0, width, height);
ctx.font = FONT;
ctx.textBaseline = "middle";
let shakeX = 0;
let shakeY = 0;
if (state.shakeUntil && now < state.shakeUntil) {
shakeX = (Math.random() * 2 - 1) * SHAKE_MAGNITUDE;
shakeY = (Math.random() * 2 - 1) * SHAKE_MAGNITUDE;
}
const iconX = PADDING + shakeX;
const iconY = height - PADDING - ICON_SIZE + shakeY;
const labelX = iconX + ICON_SIZE + PADDING;
const labelY = iconY + ICON_SIZE / 2;
const bitmap = getEmoteBitmap(combo.url, state);
if (bitmap) {
ctx.drawImage(bitmap, iconX, iconY, ICON_SIZE, ICON_SIZE);
} else if (combo.name) {
ctx.fillStyle = LABEL_COLOR;
ctx.fillText(combo.name, iconX, labelY);
}
ctx.fillStyle = LABEL_COLOR;
ctx.fillText(`${combo.count} x Combo`, labelX, labelY);
}
function init() { }
function tick(context, state) {
const { chatMessages, emoteCatalog } = context;
if (!context?.ctx) {
return;
}
ensureState(state);
const messages = Array.isArray(chatMessages) ? chatMessages : [];
const catalogMap = buildCatalogMap(emoteCatalog);
processNewMessages(messages, state, catalogMap);
const now = Date.now();
const combo = resolveDisplayedCombo(state, now);
if (!combo) {
context.ctx.clearRect(0, 0, context.width, context.height);
return;
}
drawCombo(context, state, combo, now);
}