您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds desktop notifications for things such as: AFK detection, alien spawn and more
// ==UserScript== // @name FlatMMO Desktop Notifications // @namespace com.pizza1337.flatmmo.desktopnotifications // @version 1.0.3 // @description Adds desktop notifications for things such as: AFK detection, alien spawn and more // @author Pizza1337 // @match *://flatmmo.com/play.php* // @grant none // @require https://update.greasyfork.org/scripts/544062/FlatMMOPlus.js // @license MIT // ==/UserScript== (function() { 'use strict'; const SOUND_LIBRARY = Object.freeze({ alien_ping: { label: 'Alien Ping', synth: { duration: 0.85, envelope: { attack: 0.02, hold: 0.15, decay: 0.2, sustain: 0.4, release: 0.3 }, layers: [ { type: 'square', gain: 0.8, sequence: [ { time: 0, freq: 520 }, { time: 0.25, freq: 680 }, { time: 0.5, freq: 460 } ], vibrato: { freq: 9, depth: 12 } }, { type: 'triangle', gain: 0.5, sequence: [ { time: 0, freq: 260 }, { time: 0.4, freq: 330 } ] } ] } }, glass_tinkle: { label: 'Glass Tinkle', synth: { duration: 0.6, envelope: { attack: 0.004, hold: 0.08, decay: 0.18, sustain: 0.35, release: 0.22 }, layers: [ { type: 'sine', gain: 0.8, sequence: [ { time: 0, freq: 1200 }, { time: 0.12, freq: 1450 }, { time: 0.3, freq: 1100 } ] }, { type: 'triangle', gain: 0.3, sequence: [ { time: 0, freq: 600 }, { time: 0.2, freq: 820 } ] } ] } }, sparkle_drop: { label: 'Sparkle Drop', synth: { duration: 0.65, envelope: { attack: 0.006, hold: 0.05, decay: 0.18, sustain: 0.3, release: 0.2 }, layers: [ { type: 'square', gain: 0.7, sequence: [ { time: 0, freq: 980 }, { time: 0.14, freq: 1280 } ] }, { type: 'sine', gain: 0.35, sequence: [ { time: 0, freq: 490 }, { time: 0.24, freq: 640 } ] } ] } }, comet_ping: { label: 'Comet Ping', synth: { duration: 0.75, envelope: { attack: 0.008, hold: 0.07, decay: 0.22, sustain: 0.32, release: 0.28 }, layers: [ { type: 'sawtooth', gain: 0.6, sequence: [ { time: 0, freq: 760 }, { time: 0.3, freq: 910 }, { time: 0.5, freq: 700 } ] }, { type: 'sine', gain: 0.45, sequence: [ { time: 0, freq: 380 }, { time: 0.4, freq: 520 } ] } ] } }, ember_click: { label: 'Ember Click', synth: { duration: 0.4, envelope: { attack: 0.003, hold: 0.04, decay: 0.12, sustain: 0.25, release: 0.18 }, layers: [ { type: 'square', gain: 0.8, sequence: [ { time: 0, freq: 840 }, { time: 0.1, freq: 980 } ] }, { type: 'triangle', gain: 0.35, sequence: [ { time: 0, freq: 420 }, { time: 0.16, freq: 620 } ] } ] } }, signal_tick: { label: 'Signal Tick', synth: { duration: 0.45, envelope: { attack: 0.003, hold: 0.03, decay: 0.1, sustain: 0.2, release: 0.15 }, layers: [ { type: 'square', gain: 0.85, sequence: [ { time: 0, freq: 920 }, { time: 0.08, freq: 1260 } ] }, { type: 'sine', gain: 0.4, sequence: [ { time: 0, freq: 310 }, { time: 0.2, freq: 520 } ] } ] } } }); const DEFAULT_SOUND_KEY = 'alien_ping'; const DEFAULT_AFK_SOUND_KEY = 'glass_tinkle'; const ALIEN_SOUND_OPTIONS = Object.freeze( Object.entries(SOUND_LIBRARY).map(([value, meta]) => ({ value, label: meta.label })) ); const NPC_TRACKING_NAMES = Object.freeze(['alien']); const ALIEN_ICON = 'https://flatmmo.com/images/npcs/alien_stand1.png'; const AFK_ICON = 'https://flatmmo.com/images/ui/sleep.png'; const AFK_IDLE_ANIMATION = 'stand'; const AFK_CHECK_INTERVAL_MS = 1000; const AFK_INTERACTION_EVENTS = Object.freeze(['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel']); const AFK_INTERACTION_THROTTLE_MS = 250; const SKILL_XP_VAR_KEYS = Object.freeze(['archery_xp', 'brewing_xp', 'cooking_xp', 'crafting_xp', 'enchantment_xp', 'farming_xp', 'firemake_xp', 'fishing_xp', 'forging_xp', 'health_xp', 'hunting_xp', 'magic_xp', 'melee_xp', 'mining_xp', 'woodcutting_xp', 'worship_xp']); const DEFAULT_CONFIG = { alienNotify: true, alienSound: true, alienSoundChoice: DEFAULT_SOUND_KEY, alienSoundVolume: 100, afkNotify: true, afkSound: true, afkSoundChoice: DEFAULT_AFK_SOUND_KEY, afkSoundVolume: 100, afkDurationValue: '30', afkDurationUnits: 'seconds' }; const DESPAWN_GRACE_MS = 600000; class DesktopNotificationsPlugin extends FlatMMOPlusPlugin { constructor() { super('desktop-notifications', { about: { name: GM_info.script.name, version: GM_info.script.version, author: GM_info.script.author, description: GM_info.script.description }, config: [ { type: 'label', label: 'Alien spawn alerts' }, { id: 'alienNotify', label: 'Notification', type: 'boolean', default: true }, { id: 'alienSound', label: 'Sound', type: 'boolean', default: true }, { id: 'alienSoundChoice', label: ' ', type: 'select', options: ALIEN_SOUND_OPTIONS, default: DEFAULT_SOUND_KEY }, { id: 'alienSoundVolume', label: 'Volume (%)', type: 'range', min: 0, max: 100, step: 10, default: 100 }, { type: 'label', label: 'AFK detection alerts' }, { id: 'afkNotify', label: 'Notification', type: 'boolean', default: true }, { id: 'afkSound', label: 'Sound', type: 'boolean', default: true }, { id: 'afkSoundChoice', label: ' ', type: 'select', options: ALIEN_SOUND_OPTIONS, default: DEFAULT_AFK_SOUND_KEY }, { id: 'afkSoundVolume', label: 'Volume (%)', type: 'range', min: 0, max: 100, step: 10, default: 100 }, { id: 'afkDurationValue', label: 'AFK threshold', type: 'select', options: Array.from({ length: 60 }, function (_, i) { return { value: String(i + 1), label: String(i + 1) }; }), default: '30' }, { id: 'afkDurationUnits', label: 'AFK threshold units', type: 'select', options: [ { value: 'seconds', label: 'Seconds' }, { value: 'minutes', label: 'Minutes' } ], default: 'seconds' } ] }); this.alienPresent = false; this.lastSeenTimestamp = 0; this.despawnTimer = null; this.audioContext = null; this.configCache = this.buildDefaultCache(); this._permissionPromise = null; this.soundTesterObserver = null; this.afkMonitorId = null; this.afkState = this.createInitialAfkState(); this.afkAnimationWarningLogged = false; this.afkXpWarningLogged = false; this.xpVarWarningLogged = false; this.lastTotalXpFromVars = NaN; this.animationGuardWarningLogged = false; this.npcLookupWarningLogged = false; this.boundAfkInteractionHandler = null; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.initSoundTester(), { once: true }); } else { this.initSoundTester(); } this.startAfkMonitor(); this.readTotalXpFromVars(); this.registerAfkInteractionGuards(); if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('beforeunload', () => this.stopAfkMonitor(), { once: true }); } } onPaintNpcs() { this.scanTrackedNpcPresence(); } onConfigsChanged() { this.configCache = this.buildConfigCache(); if (this.alienPresent) { this.refreshPresenceTimer(); } this.ensureSoundTester(); this.resetAfkState(); this.afkAnimationWarningLogged = false; } buildDefaultCache() { return this.normalizeConfig(DEFAULT_CONFIG); } buildConfigCache() { const afkDurationValue = (() => { const value = this.getConfig('afkDurationValue'); return value !== undefined ? value : DEFAULT_CONFIG.afkDurationValue; })(); const afkDurationUnits = this.getStringConfig('afkDurationUnits', DEFAULT_CONFIG.afkDurationUnits); return this.normalizeConfig({ alienNotify: this.getBooleanConfig('alienNotify', DEFAULT_CONFIG.alienNotify), alienSound: this.getBooleanConfig('alienSound', DEFAULT_CONFIG.alienSound), alienSoundChoice: this.getStringConfig('alienSoundChoice', DEFAULT_CONFIG.alienSoundChoice), alienSoundVolume: this.getNumberConfig('alienSoundVolume', DEFAULT_CONFIG.alienSoundVolume), afkNotify: this.getBooleanConfig('afkNotify', DEFAULT_CONFIG.afkNotify), afkSound: this.getBooleanConfig('afkSound', DEFAULT_CONFIG.afkSound), afkSoundChoice: this.getStringConfig('afkSoundChoice', DEFAULT_CONFIG.afkSoundChoice), afkSoundVolume: this.getNumberConfig('afkSoundVolume', DEFAULT_CONFIG.afkSoundVolume), afkDurationValue, afkDurationUnits }); } normalizeConfig(raw) { const alienSoundKey = (typeof raw.alienSoundChoice === 'string' && SOUND_LIBRARY[raw.alienSoundChoice]) ? raw.alienSoundChoice : DEFAULT_SOUND_KEY; if (raw.alienSoundChoice !== alienSoundKey) { this.ensureConfigValue('alienSoundChoice', alienSoundKey); } const alienVolumePercent = this.clampPercent(raw.alienSoundVolume); const afkSoundKey = (typeof raw.afkSoundChoice === 'string' && SOUND_LIBRARY[raw.afkSoundChoice]) ? raw.afkSoundChoice : DEFAULT_AFK_SOUND_KEY; if (raw.afkSoundChoice !== afkSoundKey) { this.ensureConfigValue('afkSoundChoice', afkSoundKey); } const afkVolumePercent = this.clampPercent(raw.afkSoundVolume ?? DEFAULT_CONFIG.afkSoundVolume); let durationValue = null; if (typeof raw.afkDurationValue === 'number' && Number.isFinite(raw.afkDurationValue)) { durationValue = raw.afkDurationValue; } else if (typeof raw.afkDurationValue === 'string' && raw.afkDurationValue.trim() !== '') { const parsed = Number.parseInt(raw.afkDurationValue, 10); if (!Number.isNaN(parsed)) { durationValue = parsed; } } if (!Number.isFinite(durationValue)) { const fallback = Number.parseInt(DEFAULT_CONFIG.afkDurationValue, 10); durationValue = Number.isNaN(fallback) ? 30 : fallback; } const clampedDurationValue = Math.min(60, Math.max(1, Math.round(durationValue))); let durationUnits = (typeof raw.afkDurationUnits === 'string' && raw.afkDurationUnits.toLowerCase() === 'minutes') ? 'minutes' : 'seconds'; const thresholdMs = clampedDurationValue * (durationUnits === 'minutes' ? 60000 : 1000); const unitLabel = durationUnits === 'minutes' ? (clampedDurationValue === 1 ? 'minute' : 'minutes') : (clampedDurationValue === 1 ? 'second' : 'seconds'); const thresholdLabel = `${clampedDurationValue} ${unitLabel}`; return { alien: { notify: !!raw.alienNotify, sound: !!raw.alienSound, soundKey: alienSoundKey, volumePercent: alienVolumePercent, volumeLevel: alienVolumePercent / 1000 }, afk: { notify: !!raw.afkNotify, sound: !!raw.afkSound, soundKey: afkSoundKey, volumePercent: afkVolumePercent, volumeLevel: afkVolumePercent / 1000, durationValue: clampedDurationValue, durationUnits, thresholdMs, thresholdLabel } }; } clampPercent(value) { const num = Number(value); if (!Number.isFinite(num)) { return 0; } return Math.min(100, Math.max(0, Math.round(num))); } getBooleanConfig(name, fallback) { const value = this.getConfig(name); if (typeof value === 'boolean') { return value; } return fallback; } getNumberConfig(name, fallback) { const value = this.getConfig(name); if (typeof value === 'number' && !Number.isNaN(value)) { return value; } if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); if (!Number.isNaN(parsed)) { return parsed; } } return fallback; } getStringConfig(name, fallback) { const value = this.getConfig(name); if (typeof value === 'string' && value.trim() !== '') { return value; } return fallback; } ensureConfigValue(name, value) { try { if (typeof this.setConfig === 'function') { this.setConfig(name, value); return true; } if (typeof FlatMMOPlus !== 'undefined') { if (typeof FlatMMOPlus.updateConfig === 'function') { FlatMMOPlus.updateConfig(this.id, name, value); return true; } if (typeof FlatMMOPlus.setConfig === 'function') { FlatMMOPlus.setConfig(this.id, name, value); return true; } } } catch (err) { // ignore inability to coerce stored config } return false; } createInitialAfkState() { return { standSince: null, baselineXp: null, lastXpValue: null, lastXpTimestamp: 0, lastInteraction: 0, lastAnimation: null, notified: false }; } resetAfkState() { if (!this.afkState) { this.afkState = this.createInitialAfkState(); return; } this.afkState.standSince = null; this.afkState.baselineXp = null; this.afkState.notified = false; this.afkState.lastAnimation = null; } registerAfkInteractionGuards() { if (typeof window === 'undefined') { return; } if (this.boundAfkInteractionHandler) { return; } const handler = () => this.handleUserInteraction(); AFK_INTERACTION_EVENTS.forEach((evt) => { try { window.addEventListener(evt, handler, true); } catch (err) { // ignore inability to attach interaction guards } }); this.boundAfkInteractionHandler = handler; } handleUserInteraction() { const now = Date.now(); if (!this.afkState) { this.afkState = this.createInitialAfkState(); } const last = this.afkState.lastInteraction ?? 0; if (now - last < AFK_INTERACTION_THROTTLE_MS) { return; } this.afkState.lastInteraction = now; this.afkState.standSince = null; this.afkState.baselineXp = null; this.afkState.notified = false; } startAfkMonitor() { this.stopAfkMonitor(); if (typeof window === 'undefined' || typeof window.setInterval !== 'function') { return; } this.afkMonitorId = window.setInterval(() => this.pollAfkState(), AFK_CHECK_INTERVAL_MS); } stopAfkMonitor() { if (this.afkMonitorId !== null) { if (typeof window !== 'undefined' && typeof window.clearInterval === 'function') { window.clearInterval(this.afkMonitorId); } this.afkMonitorId = null; } } pollAfkState() { if (!this.afkState) { this.afkState = this.createInitialAfkState(); } const cache = this.configCache; const afkConfig = cache?.afk; if (!afkConfig) { this.resetAfkState(); return; } const wantsNotification = !!afkConfig.notify; const wantsSound = !!afkConfig.sound; if (!wantsNotification && !wantsSound) { this.resetAfkState(); return; } const thresholdMs = afkConfig.thresholdMs; if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) { this.resetAfkState(); return; } if (typeof window === 'undefined') { return; } const normalized = this.readLocalAnimationName(); if (!normalized) { if (!this.afkAnimationWarningLogged) { console.warn('[DesktopNotifications] Unable to determine local animation; AFK detection paused'); this.afkAnimationWarningLogged = true; } this.resetAfkState(); return; } this.afkAnimationWarningLogged = false; const now = Date.now(); const totalXp = this.getCurrentTotalXp(); const hasXp = Number.isFinite(totalXp); if (hasXp) { this.afkXpWarningLogged = false; this.afkState.lastXpValue = totalXp; this.afkState.lastXpTimestamp = now; } const isStanding = normalized === AFK_IDLE_ANIMATION; if (isStanding) { if (!this.afkState.standSince) { this.afkState.standSince = now; } if (!hasXp) { this.afkState.baselineXp = null; this.afkState.standSince = now; this.afkState.notified = false; } else if (!Number.isFinite(this.afkState.baselineXp)) { this.afkState.baselineXp = totalXp; this.afkState.standSince = now; } else if (totalXp !== this.afkState.baselineXp) { this.afkState.baselineXp = totalXp; this.afkState.standSince = now; this.afkState.notified = false; } const canDetermineAfk = hasXp && Number.isFinite(this.afkState.baselineXp); if (canDetermineAfk && !this.afkState.notified && now - this.afkState.standSince >= thresholdMs && totalXp <= this.afkState.baselineXp) { this.afkState.notified = true; this.fireAfkAlert(); } } else { this.resetAfkState(); if (hasXp) { this.afkState.lastXpValue = totalXp; this.afkState.lastXpTimestamp = now; } } if (!hasXp && !this.afkXpWarningLogged) { console.warn('[DesktopNotifications] Unable to read total XP; AFK detection requires XP tracking.'); this.afkXpWarningLogged = true; } this.afkState.lastAnimation = normalized; } resolveGameWindow() { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) { return unsafeWindow; } return typeof window !== 'undefined' ? window : null; } readLocalAnimationName() { const scope = this.resolveGameWindow(); if (!scope) { return AFK_IDLE_ANIMATION; } try { const globals = scope && scope.Globals ? scope.Globals : null; const username = globals && typeof globals.local_username === 'string' ? globals.local_username : ''; const active = scope.active_animations; if (username && active && typeof active === 'object') { let entry = active[username]; if (!entry || typeof entry !== 'object') { entry = active[username] = { animation_name: AFK_IDLE_ANIMATION }; return AFK_IDLE_ANIMATION; } const name = typeof entry.animation_name === 'string' ? entry.animation_name : ''; if (name) { return name.toLowerCase(); } entry.animation_name = AFK_IDLE_ANIMATION; return AFK_IDLE_ANIMATION; } } catch (err) { // ignore active animation lookup errors } const getter = scope.get_current_local_animation; if (typeof getter === 'function') { try { const value = getter.call(scope); if (typeof value === 'string' && value) { const lower = value.toLowerCase(); try { const globals = scope && scope.Globals ? scope.Globals : null; const username = globals && typeof globals.local_username === 'string' ? globals.local_username : ''; if (username) { if (!scope.active_animations || typeof scope.active_animations !== 'object') { scope.active_animations = {}; } const entry = scope.active_animations[username] || (scope.active_animations[username] = {}); entry.animation_name = lower; } } catch (innerErr) { // ignore hydration errors } return lower; } } catch (err) { // ignore errors from get_current_local_animation } } return AFK_IDLE_ANIMATION; } guardAnimationGetter() { if (DesktopNotificationsPlugin._animationGuarded) { return; } const scope = this.resolveGameWindow(); if (!scope || typeof scope.get_current_local_animation !== 'function') { return; } const original = scope.get_current_local_animation; if (original.__desktopNotifyGuarded) { DesktopNotificationsPlugin._animationGuarded = true; return; } const plugin = this; function guardedAnimationGetter() { try { const value = original.apply(this, arguments); if (typeof value === 'string' && value) { return value; } } catch (err) { if (!plugin.animationGuardWarningLogged) { console.debug('[DesktopNotifications] Guarded get_current_local_animation()', err); plugin.animationGuardWarningLogged = true; } return plugin.recoverLocalAnimation(scope); } return plugin.recoverLocalAnimation(scope); } guardedAnimationGetter.__desktopNotifyGuarded = true; scope.get_current_local_animation = guardedAnimationGetter; DesktopNotificationsPlugin._animationGuarded = true; } recoverLocalAnimation(scope) { try { const globals = scope && scope.Globals ? scope.Globals : null; const username = globals && typeof globals.local_username === 'string' ? globals.local_username : ''; if (!username) { return AFK_IDLE_ANIMATION; } if (!scope.active_animations || typeof scope.active_animations !== 'object') { scope.active_animations = {}; } let entry = scope.active_animations[username]; if (!entry || typeof entry !== 'object') { entry = scope.active_animations[username] = { animation_name: AFK_IDLE_ANIMATION }; } else if (typeof entry.animation_name !== 'string' || !entry.animation_name) { entry.animation_name = AFK_IDLE_ANIMATION; } return entry.animation_name; } catch (err) { return AFK_IDLE_ANIMATION; } } readTotalXpFromVars() { const scope = this.resolveGameWindow(); if (!scope) { return Number.isFinite(this.lastTotalXpFromVars) ? this.lastTotalXpFromVars : NaN; } const readVar = (key) => { let value = NaN; if (typeof scope.get_var === 'function') { try { value = scope.get_var(key); } catch (err) { // ignore get_var errors } } if (!Number.isFinite(value)) { try { const loose = scope['var_' + key]; value = this.parseVarNumber(loose); } catch (err) { // ignore loose var errors } } if (!Number.isFinite(value)) { try { const fallback = scope[key]; value = this.parseVarNumber(fallback); } catch (err) { // ignore fallback errors } } return Number.isFinite(value) ? value : NaN; }; let total = 0; let foundAny = false; for (const key of SKILL_XP_VAR_KEYS) { const value = readVar(key); if (Number.isFinite(value)) { total += value; foundAny = true; } } if (foundAny) { this.lastTotalXpFromVars = total; this.xpVarWarningLogged = false; return total; } if (typeof scope.get_var === 'function') { try { const fallbackGlobal = scope.get_var('global_xp'); if (Number.isFinite(fallbackGlobal)) { this.lastTotalXpFromVars = fallbackGlobal; this.xpVarWarningLogged = false; return fallbackGlobal; } } catch (err) { // ignore fallback errors } } if (!this.xpVarWarningLogged) { console.warn('[DesktopNotifications] Unable to read skill XP vars; relying on DOM totals.'); this.xpVarWarningLogged = true; } return Number.isFinite(this.lastTotalXpFromVars) ? this.lastTotalXpFromVars : NaN; } getCurrentTotalXp() { const fromVars = this.readTotalXpFromVars(); if (Number.isFinite(fromVars)) { return fromVars; } return this.readTotalXpFromDom(); } readTotalXpFromDom() { if (typeof document === 'undefined') { return NaN; } try { const globalEl = document.getElementById('ui-skill-global-xp'); if (globalEl) { const value = this.extractFirstNumber(globalEl.textContent || globalEl.innerText || ''); if (Number.isFinite(value)) { return value; } } const xpNodes = document.querySelectorAll("span[id^='ui-skill-'][id$='-xp']"); let total = 0; let found = false; xpNodes.forEach((node) => { if (node && typeof node.id === 'string' && node.id.indexOf('ui-skill-global-xp') !== -1) { return; } const value = this.extractFirstNumber((node?.textContent) || (node?.innerText) || ''); if (Number.isFinite(value)) { total += value; found = true; } }); return found ? total : NaN; } catch (err) { return NaN; } } parseVarNumber(raw) { if (typeof raw === 'number') { return Number.isFinite(raw) ? raw : NaN; } if (typeof raw === 'string') { const digits = raw.replace(/[^0-9-]/g, ''); if (!digits) { return NaN; } const parsed = Number.parseInt(digits, 10); return Number.isFinite(parsed) ? parsed : NaN; } return NaN; } extractFirstNumber(raw) { if (typeof raw !== 'string') { raw = raw == null ? '' : String(raw); } const match = raw.match(/(\d[\d,]*)/); if (!match) { return NaN; } const digits = match[1].replace(/[^0-9]/g, ''); if (!digits) { return NaN; } const parsed = Number.parseInt(digits, 10); return Number.isFinite(parsed) ? parsed : NaN; } fireAfkAlert() { const cache = this.configCache; const afkConfig = cache?.afk; if (!afkConfig) { return; } if (afkConfig.notify) { const message = `You've been idle for ${afkConfig.thresholdLabel}.`; this.showNotification('AFK detected', message, AFK_ICON); } if (afkConfig.sound) { this.playLibrarySound(afkConfig.soundKey, afkConfig.volumeLevel); } } listTrackedNpcNames() { return NPC_TRACKING_NAMES.slice(); } scanTrackedNpcPresence() { const entries = this.readVisibleNpcEntries(); if (!entries.length) { return; } if (entries.some((npc) => this.isTrackedAlienNpc(npc))) { this.handleAlienSeen(); } } listVisibleNpcIdentifiers() { return this.readVisibleNpcEntries().map((npc) => ({ uuid: typeof npc?.uuid === 'string' ? npc.uuid : '', name: typeof npc?.name === 'string' ? npc.name : '', label: typeof npc?.label === 'string' ? npc.label : '' })); } readVisibleNpcEntries() { const source = this.safeNpcTable(); if (!source) { return []; } const entries = []; for (const value of Object.values(source)) { if (!value || typeof value !== 'object') { continue; } if (value.is_hidden) { continue; } entries.push(value); } return entries; } safeNpcTable() { if (typeof window === 'undefined') { return null; } try { const scope = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; let table = null; if (scope && typeof scope.npcs === 'object' && scope.npcs) { table = scope.npcs; } else if (typeof npcs !== 'undefined' && npcs && typeof npcs === 'object') { table = npcs; } if (!table || typeof table !== 'object') { return null; } this.npcLookupWarningLogged = false; return table; } catch (err) { if (!this.npcLookupWarningLogged) { console.warn('[DesktopNotifications] Unable to inspect NPC table', err); this.npcLookupWarningLogged = true; } return null; } } isTrackedAlienNpc(npc) { const identifier = this.normalizeNpcIdentifier(npc); return identifier ? NPC_TRACKING_NAMES.includes(identifier) : false; } normalizeNpcIdentifier(npc) { if (!npc) { return ''; } if (typeof npc.name === 'string' && npc.name.trim() !== '') { return npc.name.trim().toLowerCase().replace(/\s+/g, '_'); } if (typeof npc.label === 'string' && npc.label.trim() !== '') { return npc.label.trim().toLowerCase().replace(/\s+/g, '_'); } return ''; } handleAlienSeen() { const now = Date.now(); this.lastSeenTimestamp = now; if (!this.alienPresent) { this.alienPresent = true; this.fireAlienAlert(); } this.refreshPresenceTimer(); } refreshPresenceTimer() { if (this.despawnTimer) { clearTimeout(this.despawnTimer); this.despawnTimer = null; } if (!this.alienPresent) { return; } this.despawnTimer = window.setTimeout(() => this.checkDespawn(), DESPAWN_GRACE_MS + 100); } checkDespawn() { if (!this.alienPresent) { return; } if (Date.now() - this.lastSeenTimestamp >= DESPAWN_GRACE_MS) { this.alienPresent = false; this.lastSeenTimestamp = 0; this.despawnTimer = null; } else { this.refreshPresenceTimer(); } } fireAlienAlert() { const alienConfig = this.configCache?.alien; if (!alienConfig) { return; } if (alienConfig.notify) { this.showNotification('Alien sighted!', 'An alien has appeared on your map.', ALIEN_ICON); } if (alienConfig.sound) { this.playLibrarySound(alienConfig.soundKey, alienConfig.volumeLevel); } } ensureNotificationPermission() { if (!('Notification' in window)) { return Promise.resolve('denied'); } if (Notification.permission !== 'default') { return Promise.resolve(Notification.permission); } if (this._permissionPromise) { return this._permissionPromise; } this._permissionPromise = new Promise((resolve) => { const finish = (permission) => { this._permissionPromise = null; resolve(permission); }; try { const result = Notification.requestPermission((permission) => finish(permission)); if (result && typeof result.then === 'function') { result.then(finish).catch(() => finish('denied')); } else if (typeof result === 'string') { finish(result); } else { window.setTimeout(() => finish(Notification.permission), 0); } } catch (err) { finish('denied'); } }); return this._permissionPromise; } async showNotification(title, body, icon = ALIEN_ICON) { try { const permission = await this.ensureNotificationPermission(); if (permission !== 'granted') { return; } const notification = new Notification(title, { body, icon }); window.setTimeout(() => { try { notification.close(); } catch (err) { // ignore inability to close notifications } }, 10000); } catch (err) { console.warn('[DesktopNotifications] Unable to show notification', err); } } playLibrarySound(soundKey, volumeLevel) { const sound = SOUND_LIBRARY[soundKey] || SOUND_LIBRARY[DEFAULT_SOUND_KEY]; if (!sound) { return; } if (sound.synth) { this.playSynthSound(sound.synth, volumeLevel); } else if (sound.url) { this.playAudioElement(sound.url, volumeLevel); } } playAudioElement(url, volumeLevel) { try { const audio = new Audio(url); audio.crossOrigin = 'anonymous'; audio.volume = Math.max(0, Math.min(1, volumeLevel)); audio.play().catch(() => {}); } catch (err) { console.warn('[DesktopNotifications] Failed to play audio element', err); } } initSoundTester() { if (typeof MutationObserver === 'function' && !this.soundTesterObserver) { const target = document.body || document.documentElement; if (target) { this.soundTesterObserver = new MutationObserver(() => this.ensureSoundTester()); this.soundTesterObserver.observe(target, { childList: true, subtree: true }); } } this.ensureSoundTester(); } ensureSoundTester() { let attached = false; ['alien', 'afk'].forEach((scope) => { attached = this.attachSoundTester(scope) || attached; }); return attached; } attachSoundTester(scope) { const selectId = `flatmmoplus-config-${this.id}-${scope}SoundChoice`; const select = document.getElementById(selectId); if (!select) { return false; } if (select.dataset.desktopNotifyTesterAttached === '1') { return true; } const parent = select.parentElement; if (!parent) { return false; } const button = document.createElement('button'); button.type = 'button'; button.textContent = 'Play'; button.style.marginLeft = '6px'; button.className = 'desktop-notify-play-button'; button.addEventListener('click', () => { const selectEl = document.getElementById(selectId); const volumeEl = document.getElementById(`flatmmoplus-config-${this.id}-${scope}SoundVolume`); const configSegment = this.configCache?.[scope] || {}; const defaultSoundKey = scope === 'afk' ? DEFAULT_AFK_SOUND_KEY : DEFAULT_SOUND_KEY; const selectedKey = (selectEl && selectEl.value) || configSegment.soundKey || defaultSoundKey; let volumePercent = configSegment.volumePercent ?? (scope === 'afk' ? DEFAULT_CONFIG.afkSoundVolume : DEFAULT_CONFIG.alienSoundVolume); if (volumeEl && volumeEl.value !== '') { const parsed = Number(volumeEl.value); if (!Number.isNaN(parsed)) { volumePercent = this.clampPercent(parsed); } } this.playLibrarySound(selectedKey, Math.max(0, Math.min(1, volumePercent / 1000))); }); parent.appendChild(button); select.dataset.desktopNotifyTesterAttached = '1'; return true; } ensureAudioContext() { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) { return null; } if (!this.audioContext || this.audioContext.state === 'closed') { this.audioContext = new Ctx(); } if (this.audioContext.state === 'suspended') { this.audioContext.resume().catch(() => {}); } return this.audioContext; } playSynthSound(descriptor, volumeLevel) { const ctx = this.ensureAudioContext(); if (!ctx) { return; } const now = ctx.currentTime; const duration = Math.max(0.1, descriptor?.duration ?? 1); const envelope = descriptor?.envelope || {}; const attack = Math.max(0.005, envelope.attack ?? 0.05); const hold = Math.max(0, envelope.hold ?? 0.2); const decay = Math.max(0, envelope.decay ?? 0.3); const sustainLevel = Math.max(0.05, Math.min(1, envelope.sustain ?? 0.5)); const release = Math.max(0.05, envelope.release ?? 0.4); const peak = Math.max(0.0001, Math.min(1, volumeLevel ?? 0.05)); const masterGain = ctx.createGain(); masterGain.gain.setValueAtTime(0.0001, now); masterGain.gain.exponentialRampToValueAtTime(peak, now + attack); masterGain.gain.setValueAtTime(peak, now + attack + hold); const decayTarget = Math.max(0.0001, peak * sustainLevel); masterGain.gain.exponentialRampToValueAtTime(decayTarget, now + attack + hold + decay); const stopTime = now + duration; masterGain.gain.setValueAtTime(decayTarget, stopTime); masterGain.gain.exponentialRampToValueAtTime(0.0001, stopTime + release); masterGain.connect(ctx.destination); const cleanupFns = []; const scheduleCleanup = (() => { let cleaned = false; return () => { if (cleaned) { return; } cleaned = true; cleanupFns.forEach((fn) => { try { fn(); } catch (err) { // ignore cleanup errors } }); }; })(); cleanupFns.push(() => { try { masterGain.disconnect(); } catch (err) { // ignore disconnect issues } }); const layers = Array.isArray(descriptor?.layers) ? descriptor.layers : []; layers.forEach((layer) => { try { const osc = ctx.createOscillator(); osc.type = layer?.type || 'sine'; if (typeof layer?.detune === 'number') { osc.detune.value = layer.detune; } const layerGain = ctx.createGain(); const layerScale = Math.max(0, Math.min(2, layer?.gain ?? 1)); layerGain.gain.setValueAtTime(layerScale, now); layerGain.connect(masterGain); osc.connect(layerGain); const sequence = Array.isArray(layer?.sequence) && layer.sequence.length > 0 ? layer.sequence : [{ time: 0, freq: layer?.freq || 440 }]; sequence.forEach((step, index) => { const clampedTime = now + Math.min(Math.max(step?.time ?? 0, 0), duration); const freq = Math.max(20, step?.freq ?? layer?.freq ?? 440); if (index === 0) { osc.frequency.setValueAtTime(freq, clampedTime); } else { const glide = (layer?.glide || 'linear').toLowerCase(); if (glide === 'exponential') { osc.frequency.exponentialRampToValueAtTime(freq, clampedTime); } else { osc.frequency.linearRampToValueAtTime(freq, clampedTime); } } }); if (layer?.vibrato && typeof layer.vibrato.freq === 'number' && typeof layer.vibrato.depth === 'number') { const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = Math.max(0.1, layer.vibrato.freq); const lfoGain = ctx.createGain(); lfoGain.gain.value = layer.vibrato.depth; lfo.connect(lfoGain); lfoGain.connect(osc.frequency); lfo.start(now); const lfoStop = stopTime + release + 0.05; lfo.stop(lfoStop); cleanupFns.push(() => { try { lfo.disconnect(); lfoGain.disconnect(); } catch (err) { // ignore cleanup issues } }); } const oscStop = stopTime + release + 0.05; osc.start(now); osc.stop(oscStop); osc.onended = scheduleCleanup; cleanupFns.push(() => { try { osc.disconnect(); layerGain.disconnect(); } catch (err) { // ignore disconnect issues } }); } catch (err) { console.warn('[DesktopNotifications] Failed to create synth layer', err); } }); window.setTimeout(scheduleCleanup, Math.ceil((duration + release + 0.2) * 1000)); } } DesktopNotificationsPlugin._animationGuarded = false; const plugin = new DesktopNotificationsPlugin(); FlatMMOPlus.registerPlugin(plugin); })();