-- OT-MMO: tocadiscos de las casas (plan Portador de Luz, parte 5; vault OT-MMO/premium/plan-portador-y-casas.md). -- · El servidor (server/scripts/tocadiscos/tocadiscos.lua) dice qué suena en cada tocadiscos cuando entras en su -- radio («T»), si estás dentro o fuera de la casa («F») y cuándo deja de oírse («X»). Opcode 165: ver su cabecera. -- · Aquí: el volumen por distancia (se recalcula cada 250 ms al moverse), qué tocadiscos suena si hay varios (el que más -- se oye), «silenciar la música de otros jugadores» (global) y silenciar uno concreto, y la ventana del dueño. -- · El sonido NO lo hace el cliente: lo hace un reproductor de YouTube que siempre se ve (términos de la API de -- YouTube: nada de audio escondido, mínimo 200×200). Se le habla por archivos del directorio de escritura: -- /tocadiscos/estado.txt lo escribe este módulo: qué poner, desde qué segundo, a qué volumen (clave=valor) -- /tocadiscos/o-*.txt órdenes del reproductor: silenciar/escuchar uno, título y duración, error, ventana -- /tocadiscos/latido.txt lo reescribe el reproductor cada 2 s (si no cambia, no hay quien ponga la música) -- En el navegador lo lee la capa HTML del juego (web/tocadiscos/puente.js, en /user/.otclient/tocadiscos/); en -- Windows, ValdrenLauncher (launcher/src-tauri/src/tocadiscos.rs, en %APPDATA%\otcr\otclient\otclient\tocadiscos\). local OPCODE = 165 local PASO_MS = 250 local DIR = '/tocadiscos' tocadiscosController = Controller:new() local activo, RADIO, FUERA, COLA = false, 8, 35, 5 local sonidos = {} -- clave 'x^y^z' -> { pos, vid, autor, transc, recibido, dentro, seq } local titulos = {} -- vid -> título (lo dice el reproductor) local enviadoD, enviadoE = {}, {} -- seq -> true (duración y error, una vez) local ultimoEstado = '' local ultimoEscrito = 0 local sonando = nil -- { clave, seq, vol } lo que se le dijo al reproductor local pasoEv = nil local latidoTxt, latidoCambio = nil, 0 local avisadoSinReproductor = false local ventana, opciones = nil, nil local ventanaPos, ventanaDatos = nil, nil local intentoOpciones = 0 local crearOpciones -- (se define abajo; el bucle la reintenta) local function enviar(texto) local protocol = g_game.getProtocolGame() if protocol then protocol:sendExtendedOpcode(OPCODE, texto) end end local function partir(s, sep) local t = {} for x in ((s or '') .. sep):gmatch('(.-)' .. sep:gsub('%p', '%%%0')) do t[#t + 1] = x end return t end local function ahora() return g_clock.millis() end local function aviso(texto) if modules.game_textmessage and modules.game_textmessage.displayStatusMessage then modules.game_textmessage.displayStatusMessage(texto) end end local function enNavegador() return g_platform.getOSName() == 'browser' end -- El reproductor manda títulos en UTF-8; la fuente del cliente es cp1252. local function aCp1252(s) s = s:gsub('[\240-\247][\128-\191][\128-\191][\128-\191]', '?') s = s:gsub('[\224-\239][\128-\191][\128-\191]', '?') s = s:gsub('[\194-\223][\128-\191]', function(c) local a, b = c:byte(1, 2) local n = (a - 192) * 64 + (b - 128) return n >= 160 and string.char(n) or '?' end) return s end local function mmss(ms) local s = math.floor((ms or 0) / 1000) return string.format('%d:%02d', math.floor(s / 60), s % 60) end -- -- Opciones (g_settings) ------------------------------------------------------------------------------------------ local function silenciados() local t = {} for _, k in ipairs(partir(g_settings.getString('tocadiscos/silenciados', ''), ';')) do if k ~= '' then t[k] = true end end return t end local function guardarSilenciados(t) local l = {} for k in pairs(t) do l[#l + 1] = k end g_settings.set('tocadiscos/silenciados', table.concat(l, ';')) end local function volumenGeneral() return g_settings.getNumber('tocadiscos/volumen', 60) end local function silenciarOtros() return g_settings.getBoolean('tocadiscos/otros', false) end -- -- Enlace (lo mismo que T.idDeEnlace del servidor; aquí solo para avisar antes de mandar) ----------------------- local HOSTS = { ['youtube.com'] = true, ['www.youtube.com'] = true, ['m.youtube.com'] = true, ['music.youtube.com'] = true } local function idDeEnlace(s) s = tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', '') if s == '' or #s > 300 then return nil end local resto = s:gsub('^[Hh][Tt][Tt][Pp][Ss]?://', '') local host, ruta = resto:match('^([^/%?#]+)(.*)$') if not host then return nil end host = host:lower() local id if host == 'youtu.be' or host == 'www.youtu.be' then id = ruta:match('^/([%w_-]+)') elseif HOSTS[host] then local q = ruta:match('^/watch%?([^#]*)') if q then id = ('&' .. q):match('&v=([%w_-]+)') else id = ruta:match('^/shorts/([%w_-]+)') or ruta:match('^/embed/([%w_-]+)') or ruta:match('^/live/([%w_-]+)') end end if id and #id == 11 then return id end return nil end -- -- Puente con el reproductor -------------------------------------------------------------------------------------- local function escribirEstado(t) local l = {} t.general = volumenGeneral() for _, k in ipairs({ 'v', 'estado', 'seq', 'vid', 'ms', 'vol', 'general', 'autor', 'clave', 'dentro', 'titulo', 'yo' }) do if t[k] ~= nil then l[#l + 1] = k .. '=' .. tostring(t[k]):gsub('[\r\n]', ' ') end end local texto = table.concat(l, '\n') .. '\n' pcall(g_resources.makeDir, DIR) pcall(g_resources.writeFileContents, DIR .. '/estado.txt', texto) ultimoEscrito = ahora() end local function nada() if ultimoEstado ~= 'nada' then escribirEstado({ v = 1, estado = 'nada' }) ultimoEstado = 'nada' end sonando = nil end local function transcurrido(s) return s.transc + (ahora() - s.recibido) end -- Volumen 0-100 para un tocadiscos según la distancia (lineal: 100 % al lado, ~11 % en el borde del radio) y si estás -- fuera de la casa. Sin multiplicar por el volumen general (eso va aparte). local function factor(s, p) if p.z ~= s.pos.z then return 0 end local dx, dy = p.x - s.pos.x, p.y - s.pos.y local d = math.sqrt(dx * dx + dy * dy) if d > RADIO + 0.5 then return 0 end local f = math.max(0, 1 - d / (RADIO + 1)) if not s.dentro then f = f * FUERA / 100 end return f end local function miNombre() local yo = g_game.getLocalPlayer() return yo and yo:getName() or '' end local function paso() pasoEv = nil if not activo then return end pasoEv = scheduleEvent(paso, PASO_MS) -- «I» llega al entrar, a veces antes de que el juego se dé por conectado: se espera sin cortar el bucle if not g_game.isOnline() then return end -- el panel de Opciones aún no existía al llegar «I»: se reintenta if not opciones and ahora() - intentoOpciones > 2000 then intentoOpciones = ahora() pcall(crearOpciones) end leerOrdenes() local yo = g_game.getLocalPlayer() local p = yo and yo:getPosition() if not p then return end local sil, otros, yoNombre = silenciados(), silenciarOtros(), miNombre() local mejor, mejorF, silenciado = nil, 0, nil for clave, s in pairs(sonidos) do local f = factor(s, p) if f > 0 then if sil[clave] then silenciado = silenciado or { s = s, motivo = 'silenciado' } elseif otros and s.autor ~= yoNombre then silenciado = silenciado or { s = s, motivo = 'otros' } elseif f > mejorF then mejor, mejorF = s, f end end end if not mejor then if silenciado then -- sin reproductor (nada suena), pero el puente enseña «silenciado · volver a oír» local s = silenciado.s local clave = s.pos.x .. '^' .. s.pos.y .. '^' .. s.pos.z local e = 'sil:' .. clave .. silenciado.motivo if ultimoEstado ~= e then escribirEstado({ v = 1, estado = silenciado.motivo, clave = clave, autor = s.autor, titulo = titulos[s.vid] or '' }) ultimoEstado = e end sonando = nil else nada() end return end local vol = math.floor(mejorF * volumenGeneral() + 0.5) local clave = mejor.pos.x .. '^' .. mejor.pos.y .. '^' .. mejor.pos.z local cambia = not sonando or sonando.seq ~= mejor.seq or sonando.clave ~= clave local cambiaVol = sonando and math.abs(sonando.vol - vol) >= 2 or (sonando and sonando.vol ~= vol and (vol == 0 or sonando.vol == 0)) if cambia or cambiaVol or ahora() - ultimoEscrito > 15000 then escribirEstado({ v = 1, estado = 'suena', seq = mejor.seq, vid = mejor.vid, ms = transcurrido(mejor), vol = vol, autor = mejor.autor, clave = clave, dentro = mejor.dentro and 1 or 0, yo = (mejor.autor == yoNombre) and 1 or 0 }) ultimoEstado = 'suena' sonando = { clave = clave, seq = mejor.seq, vol = vol } end -- Windows sin launcher abierto: nadie lee el archivo. Se avisa una vez por sesión. if not enNavegador() and not avisadoSinReproductor and cambia then scheduleEvent(function() if sonando and ahora() - latidoCambio > 6000 and not avisadoSinReproductor then avisadoSinReproductor = true aviso('Suena un tocadiscos cerca. Para oírlo en Windows, deja abierto ValdrenLauncher.') end end, 7000) end end local function porSeq(seq) for clave, s in pairs(sonidos) do if s.seq == seq then return clave, s end end end local function refrescarVentana() end -- (se define abajo) -- Órdenes que deja el reproductor (un archivo por orden: así nunca se pisan dos). function leerOrdenes() local ok, lista = pcall(g_resources.listDirectoryFiles, DIR) if not ok or not lista then return end for _, f in ipairs(lista) do local ruta = DIR .. '/' .. f if f == 'latido.txt' then local ok2, t = pcall(g_resources.readFileContents, ruta) if ok2 and t ~= latidoTxt then latidoTxt, latidoCambio = t, ahora() end elseif f:find('^o%-') then local ok2, t = pcall(g_resources.readFileContents, ruta) pcall(g_resources.deleteFile, ruta) if ok2 and t then local orden, a, b, c = t:match('^(%a+)|([^|\n]*)|?([^|\n]*)|?([^\n]*)') if orden == 'silenciar' or orden == 'escuchar' then local sil = silenciados() if a ~= '' and a:match('^%d+%^%d+%^%d+$') then sil[a] = (orden == 'silenciar') or nil guardarSilenciados(sil) end if b == 'otros' then g_settings.set('tocadiscos/otros', orden == 'silenciar') if opciones then opciones.panelOtros.otros:setChecked(orden == 'silenciar') end end if opciones then refrescarOpciones() end elseif orden == 'info' then local seq, seg = tonumber(a), tonumber(b) local clave, s = porSeq(seq) if s then if c and c ~= '' then titulos[s.vid] = aCp1252(c):sub(1, 120) end if seg and seg > 0 and not enviadoD[seq] then enviadoD[seq] = true enviar(('D|%s^%d^%d'):format(clave, seq, math.floor(seg))) end refrescarVentana() end elseif orden == 'error' then local seq, cod = tonumber(a), tonumber(b) local clave, s = porSeq(seq) if s and cod and not enviadoE[seq] then enviadoE[seq] = true enviar(('E|%s^%d^%d'):format(clave, seq, cod)) end elseif orden == 'volumen' then local v = tonumber(a) if v then g_settings.set('tocadiscos/volumen', math.max(0, math.min(100, math.floor(v)))) if opciones then refrescarOpciones() end end end end end end end -- -- Ventana del dueño ---------------------------------------------------------------------------------------------- local borrarEstadoEv = nil local function estadoVentana(texto, color) if not ventana then return end ventana.estado:setText(texto or '') ventana.estado:setColor(color or '#9AA7AE') -- los avisos se van solos: si no, «Añadida a la cola» sigue ahí minutos después if borrarEstadoEv then removeEvent(borrarEstadoEv) end borrarEstadoEv = (texto and texto ~= '') and scheduleEvent(function() borrarEstadoEv = nil if ventana then ventana.estado:setText('') end end, 6000) or nil end local function nombreVideo(vid) local t = titulos[vid] if t and t ~= '' then return t end return 'Vídeo ' .. vid end refrescarVentana = function() if not ventana or not ventana:isVisible() or not ventanaDatos then return end local d = ventanaDatos if d.vid ~= '' then ventana.cabecera:setText('Sonando ahora') ventana.titulo:setText(nombreVideo(d.vid)) local t = d.transc + (ahora() - d.recibido) local dur = d.dur > 0 and (' de ' .. mmss(d.dur * 1000)) or '' ventana.sub:setText('Puesta por ' .. d.autor .. ' · ' .. mmss(t) .. dur) else ventana.cabecera:setText('En silencio') ventana.titulo:setText('No suena nada') ventana.sub:setText('Pega un enlace de YouTube y pulsa «Poner ahora».') end ventana.parar:setEnabled(d.vid ~= '') ventana.siguiente:setEnabled(d.vid ~= '' and #d.cola > 0) ventana.aCola:setEnabled(d.vid ~= '' and #d.cola < COLA) ventana.etiquetaCola:setText(('En cola · %d de %d'):format(#d.cola, COLA)) ventana.cola:destroyChildren() for i, c in ipairs(d.cola) do local w = g_ui.createWidget('TdFila', ventana.cola) w.num:setText(tostring(i)) w.titulo:setText(nombreVideo(c.vid)) w.sub:setText('de ' .. c.autor) w.quitar.onClick = function() enviar(('K|%s^%d'):format(ventanaPos, i)) end end ventana.colaVacia:setVisible(#d.cola == 0) end local function abrirVentana(resto) local f = partir(resto, '|') ventanaPos = f[1] local a = partir(f[2] or '', '^') local d = { vid = a[1] or '', autor = a[2] or '', transc = tonumber(a[3]) or 0, dur = tonumber(a[4]) or 0, seq = tonumber(a[5]) or 0, recibido = ahora(), cola = {} } if f[3] and f[3] ~= '' then for _, l in ipairs(partir(f[3], '~')) do local c = partir(l, '^') d.cola[#d.cola + 1] = { vid = c[1], autor = c[2] or '' } end end ventanaDatos = d if not ventana then return end local abierta = ventana:isVisible() ventana.nota:setText(('Se oye a %d casillas; fuera de la casa, más bajo. Cada jugador puede silenciarlo desde su reproductor.') :format(RADIO)) ventana:show() if not abierta then ventana:raise() ventana:focus() ventana.enlace:setText('') ventana.enlace:focus() estadoVentana('') end refrescarVentana() end local function mandarEnlace(letra) if not ventana or not ventanaPos then return end local texto = ventana.enlace:getText() if not idDeEnlace(texto) then return estadoVentana('Solo enlaces de youtube.com o youtu.be.', '#DC6B6B') end enviar(letra .. '|' .. ventanaPos .. '^' .. texto) ventana.enlace:setText('') estadoVentana('') end -- Reloj de la ventana (el minuto que lleva sonando). local relojEv = nil local function reloj() relojEv = nil if ventana and ventana:isVisible() then refrescarVentana() relojEv = scheduleEvent(reloj, 1000) end end -- -- Panel de Opciones ------------------------------------------------------------------------------------------------ function refrescarOpciones() if not opciones then return end local n = 0 for _ in pairs(silenciados()) do n = n + 1 end opciones.panelVolumen.volumenTexto:setText(('Volumen de los tocadiscos: %d %%'):format(volumenGeneral())) opciones.panelSilenciados.silenciadosTexto:setText(n == 0 and 'Ningún tocadiscos silenciado uno a uno' or (n == 1 and '1 tocadiscos silenciado uno a uno' or (n .. ' tocadiscos silenciados uno a uno'))) opciones.panelSilenciados.reactivar:setEnabled(n > 0) end crearOpciones = function() if opciones or not modules.client_options or not modules.client_options.getPanel then return end local okp, panel = pcall(modules.client_options.getPanel) if not okp or not panel then return end local ok, w = pcall(g_ui.createWidget, 'TdOpciones', panel) if not ok or not w then return end opciones = w opciones.panelOtros.otros:setChecked(silenciarOtros()) opciones.panelVolumen.volumen:setValue(volumenGeneral()) opciones.nota:setText(enNavegador() and 'La música de un tocadiscos suena en un reproductor de YouTube pequeño en la esquina de la pantalla, con su botón para silenciarla.' or 'En Windows la música de un tocadiscos suena en una ventanita de ValdrenLauncher: déjalo abierto mientras juegas.') refrescarOpciones() modules.client_options.addButton('Sound', 'Tocadiscos', opciones) end local function quitarOpciones() if opciones then pcall(modules.client_options.removeButton, 'Sound', 'Tocadiscos') opciones:destroy() opciones = nil end end -- -- Opcode --------------------------------------------------------------------------------------------------------- local function onOpcode(protocol, opcode, buffer) local c, resto = buffer:sub(1, 1), buffer:sub(3) if c == 'I' then local f = partir(resto, '^') RADIO, FUERA, COLA = tonumber(f[1]) or 8, tonumber(f[2]) or 35, tonumber(f[3]) or 5 activo = true if not pasoEv then pasoEv = scheduleEvent(paso, PASO_MS) end local ok, err = pcall(crearOpciones) if not ok then g_logger.warning('[tocadiscos] panel de Opciones: ' .. tostring(err)) end elseif c == 'T' then local f = partir(resto, '^') local x, y, z = tonumber(f[1]), tonumber(f[2]), tonumber(f[3]) if x and y and z then sonidos[x .. '^' .. y .. '^' .. z] = { pos = { x = x, y = y, z = z }, vid = f[4] or '', autor = f[5] or '', transc = tonumber(f[6]) or 0, recibido = ahora(), dentro = f[7] == '1', seq = tonumber(f[8]) or 0 } end elseif c == 'F' then local f = partir(resto, '^') local s = sonidos[(f[1] or '') .. '^' .. (f[2] or '') .. '^' .. (f[3] or '')] if s then s.dentro = f[4] == '1' end elseif c == 'X' then sonidos[resto] = nil elseif c == 'V' then abrirVentana(resto) elseif c == 'A' or c == 'O' then if ventana and ventana:isVisible() then estadoVentana(resto, c == 'A' and '#DC6B6B' or '#C7A86B') else aviso(resto) end end end -- -- API para los botones del .otui y pruebas ---------------------------------------------------------------------- function cerrar() if ventana then ventana:hide() end end function ponerYa() mandarEnlace('P') end function aCola() mandarEnlace('C') end function parar() if ventanaPos then enviar('S|' .. ventanaPos) end end function siguiente() if ventanaPos then enviar('N|' .. ventanaPos) end end function opcionOtros(v) g_settings.set('tocadiscos/otros', v and true or false) end function opcionVolumen(v) g_settings.set('tocadiscos/volumen', math.floor(tonumber(v) or 60)) refrescarOpciones() end function reactivarTodos() g_settings.set('tocadiscos/silenciados', '') refrescarOpciones() end --- Para el arnés de pruebas: lo que se oye ahora. function estadoActual() return ultimoEstado, sonando end function depurar() local n = 0 for _ in pairs(sonidos) do n = n + 1 end return ('activo=%s bucle=%s sonidos=%d opciones=%s'):format(tostring(activo), tostring(pasoEv ~= nil), n, tostring(opciones ~= nil)) end function tocadiscosController:onInit() g_ui.importStyle('/game_viaje/valdren') g_ui.importStyle('tocadiscos') ventana = g_ui.createWidget('TdVentana', rootWidget) ventana:hide() ventana.enlace.onEnter = function() mandarEnlace('P') end ventana.onVisibilityChange = function(w, visible) if visible and not relojEv then relojEv = scheduleEvent(reloj, 1000) end end ProtocolGame.registerExtendedOpcode(OPCODE, onOpcode) -- lo que quedó de otra sesión no debe sonar (en el navegador /user vuelve de IndexedDB al recargar) escribirEstado({ v = 1, estado = 'nada' }) ultimoEstado = 'nada' end function tocadiscosController:onGameStart() -- se enciende con «I» del servidor (Tocadiscos.ACTIVO). «I» va en el login, pero Canary no pasa por el login al -- reconectar un personaje que seguía dentro: se pide otra vez («H»; sin Tocadiscos.ACTIVO nadie la atiende). scheduleEvent(function() if g_game.isOnline() then enviar('H|') end end, 1500) end -- Al salir: se calla y se cierra todo, pero el módulo sigue encendido (a veces el fin de la partida anterior llega -- DESPUÉS de la «I» de la nueva; el bucle no hace nada sin conexión). function tocadiscosController:onGameEnd() sonidos, enviadoD, enviadoE = {}, {}, {} nada() cerrar() quitarOpciones() end function tocadiscosController:onTerminate() self:onGameEnd() activo = false if pasoEv then removeEvent(pasoEv) pasoEv = nil end ProtocolGame.unregisterExtendedOpcode(OPCODE) if ventana then ventana:destroy() end ventana = nil end