-- OT-MMO: bola de cristal (vault OT-MMO/premium/plan-portador-y-casas.md, parte 6). Lo decide todo el servidor -- (server/scripts/bola_cristal/bola_cristal.lua, opcode 166: ver su cabecera): quién puede mirar, la invitación, los -- límites y el RETRASO (el cliente no retrasa nada: los paquetes ya le llegan tarde). -- · Ventana de la bola (usar una bola de cristal en una casa): transmisiones que puedes mirar y «Mirar». -- · Mirar: «V|nombre^invitación^retraso» -> sale de su personaje, entra como espectador («@livestream\n» -- como sesión y el nombre del que transmite) y, con «Volver» o si el otro deja de transmitir, vuelve al suyo. -- · Transmitir (Más > Bola de cristal, o «!bola»): cualquiera o solo tu lista, la lista de permitidos y el -- indicador «Te están mirando: N» mientras transmites. -- Solo Lua: en Windows funciona igual (no hay C++ del cliente; la sesión «@livestream...» es una cadena más). local OPCODE = 166 bolaController = Controller:new() local activo = false local RETRASO, MAX, MAXP = 25, 10, 30 local ventana, transmitirV, indicador, barra = nil, nil, nil, nil local ultimo = nil -- lo que se usó en el último login NORMAL (para volver) local loginOriginal = nil local estado = 'nada' -- 'nada' | 'saliendo' | 'esperando' | 'mirando' | 'volviendo' local mira = nil -- { nombre, ticket, retraso, desde } local relojEv, puntoEv = nil, nil local tx = { on = false, modo = 'privada', n = 0, max = 10, nombres = '' } local modoElegido = nil -- lo marcado en la ventana antes de empezar local permitidos = {} local filas = {} local function enviar(texto) local protocol = g_game.getProtocolGame() if protocol and g_game.isOnline() then protocol:sendExtendedOpcode(OPCODE, texto) end end local function partir(s, sep) local t = {} if s == nil or s == '' then return t end for x in (s .. sep):gmatch('(.-)' .. sep:gsub('%p', '%%%0')) do t[#t + 1] = x end return t end local function avisoJuego(texto) if modules.game_textmessage and modules.game_textmessage.displayStatusMessage then modules.game_textmessage.displayStatusMessage(texto) end end local function plural(n, uno, varios) return n == 1 and uno or varios end -- -- Estado en las ventanas --------------------------------------------------------------------------------------- local borrarEv = {} local function estadoEn(w, texto, color) local e = w and (w.estado or (w.pie and w.pie.estado)) if not e then return end e:setText(texto or '') e:setColor(color or '#9AA7AE') if borrarEv[w] then removeEvent(borrarEv[w]) end borrarEv[w] = (texto and texto ~= '') and scheduleEvent(function() borrarEv[w] = nil if e then e:setText('') end end, 6000) or nil end -- -- Indicador «Te están mirando» ---------------------------------------------------------------------------------- local function parpadeo() puntoEv = nil if not indicador or not indicador:isVisible() then return end indicador.punto:setOpacity(indicador.punto:getOpacity() > 0.6 and 0.35 or 1) puntoEv = scheduleEvent(parpadeo, 900) end local function refrescarIndicador() if not indicador then return end if tx.on and g_game.isOnline() and estado == 'nada' then local texto = tx.n == 0 and 'Transmitiendo · nadie mira todavía' or ('Te están mirando: ' .. tx.n) indicador.texto:setText(texto) indicador:setWidth(indicador.texto:getTextSize().width + 40) indicador:setTooltip(tx.n > 0 and ('Miran: ' .. tx.nombres .. '\nClic: opciones de la transmisión') or 'Clic: opciones de la transmisión') if not indicador:isVisible() then indicador:show() if not puntoEv then parpadeo() end end else indicador:hide() end end -- -- Ventana «Transmitir» ----------------------------------------------------------------------------------------- local function refrescarTransmitir() local w = transmitirV if not w then return end local modo = tx.on and tx.modo or (modoElegido or tx.modo) w.publica:setChecked(modo == 'publica') w.privada:setChecked(modo == 'privada') if tx.on then w.titulo:setText(tx.n == 0 and 'Transmitiendo' or ('Te están mirando: ' .. tx.n)) w.sub:setText(tx.n == 0 and (modo == 'publica' and 'Cualquiera con una bola de cristal puede mirarte.' or 'Solo los personajes de tu lista pueden mirarte.') or ('Miran: ' .. tx.nombres)) w.empezar:hide() w.parar:show() else w.titulo:setText('No transmites') w.sub:setText('Quien mire verá lo mismo que tú, con ' .. RETRASO .. ' s de retraso.') w.parar:hide() w.empezar:show() end w.etiquetaLista:setText(('Tu lista · %d de %d'):format(#permitidos, MAXP)) w.permitidos:destroyChildren() for _, nombre in ipairs(permitidos) do local f = g_ui.createWidget('BolaPermitido', w.permitidos) f.nombre:setText(nombre) f.quitar.onClick = function() enviar('Q|' .. nombre) end end w.listaVacia:setVisible(#permitidos == 0) w.barraPermitidos:setVisible(#permitidos > 4) w.anadir:setEnabled(#permitidos < MAXP) w.nota:setText(('Nadie que mire puede escribirte ni ver tu chat privado. Como mucho %d %s a la vez.') :format(MAX, plural(MAX, 'espectador', 'espectadores'))) end function abrirTransmitir() if not activo or not transmitirV then return end transmitirV:show() transmitirV:raise() transmitirV:focus() enviar('Z|') refrescarTransmitir() end function cerrarTransmitir() if transmitirV then transmitirV:hide() end end function toggleTransmitir() if transmitirV and transmitirV:isVisible() then cerrarTransmitir() else abrirTransmitir() end end function elegirModo(modo) modoElegido = modo if tx.on and tx.modo ~= modo then enviar('T|' .. modo) end refrescarTransmitir() end function alternarTransmision() if tx.on then enviar('P|') else enviar('T|' .. (modoElegido or tx.modo)) end end function anadir() if not transmitirV then return end local nombre = transmitirV.nombre:getText():gsub('^%s+', ''):gsub('%s+$', '') if nombre == '' then return end enviar('A|' .. nombre) transmitirV.nombre:setText('') end function estaDisponible() return activo end -- -- Ventana de la bola --------------------------------------------------------------------------------------------- local function refrescarVentana() local w = ventana if not w then return end w.explica:setText(('Ves lo mismo que ellos con %d s de retraso. Mientras miras, tu personaje sale del juego y no puedes moverte ni hablar.') :format(RETRASO)) w.lista:destroyChildren() for _, f in ipairs(filas) do local fila = g_ui.createWidget('BolaFila', w.lista) fila.nombre:setText(f.nombre) fila.ficha:setText(('Nivel %d%s'):format(f.nivel, f.clase ~= '' and (' · ' .. f.clase) or '')) fila.donde:setText(f.donde) fila.mirando:setText(f.max > 0 and ('%d/%d mirando'):format(f.n, f.max) or ('%d mirando'):format(f.n)) -- max 0 = sin límite fila.modo:setText(f.modo == 'publica' and 'Pública' or 'Te invitó') local llena = f.max > 0 and f.n >= f.max fila.mirar:setEnabled(not llena) fila.mirar:setText(llena and 'Llena' or 'Mirar') fila.mirar.onClick = function() fila.mirar:setEnabled(false) estadoEn(ventana, 'Consultando la bola...') enviar('W|' .. f.nombre) end end w.vacia:setVisible(#filas == 0) -- la ventana crece con la lista (hasta 5 filas; más, con barra) local visibles = math.max(1.6, math.min(#filas, 5)) w:setHeight(math.floor(42 + 20 + 4 + 32 + 12 + visibles * 68 + 12 + 30 + 22)) w.barraLista:setVisible(#filas > 5) w.vacia:setText('Nadie que puedas mirar está transmitiendo ahora.\nLas transmisiones privadas solo salen si su dueño te añadió a su lista.') w.pie.transmitir:setText(tx.on and 'Mi transmisión' or 'Transmitir yo') end local function abrirVentana(resto) filas = {} for _, l in ipairs(partir(resto, '~')) do local c = partir(l, '^') filas[#filas + 1] = { nombre = c[1] or '?', nivel = tonumber(c[2]) or 0, clase = c[3] or '', donde = c[4] or '', n = tonumber(c[5]) or 0, max = tonumber(c[6]) or MAX, modo = c[7] or 'publica' } end if not ventana then return end local abierta = ventana:isVisible() refrescarVentana() if not abierta then ventana:show() ventana:raise() ventana:focus() estadoEn(ventana, '') end end function cerrar() if ventana then ventana:hide() end end function actualizar() enviar('R|') end -- -- Mirar (espectador) ------------------------------------------------------------------------------------------- local function textoBarra(t, color) if barra then barra.texto:setText(t) barra.texto:setColor(color or '#E8D3A2') barra:show() barra:raise() end end local function ocultarBarra() if barra then barra:hide() end if relojEv then removeEvent(relojEv) relojEv = nil end end local function reloj() relojEv = nil if not mira then return end if estado == 'esperando' then local quedan = math.max(0, mira.retraso - math.floor((g_clock.millis() - mira.desde) / 1000)) textoBarra(('La bola de cristal se aclara... %d s · %s'):format(quedan, mira.nombre)) elseif estado == 'mirando' then local pasado = math.floor((g_clock.millis() - mira.desde) / 1000) textoBarra(pasado < mira.retraso and ('La bola de cristal se aclara... %d s · %s'):format(mira.retraso - pasado, mira.nombre) or ('Miras a %s · %d s de retraso'):format(mira.nombre, mira.retraso)) end relojEv = scheduleEvent(reloj, 500) end local function entrar(nombre, sesion, intentos) intentos = intentos or 1 if g_game.isOnline() or g_game.getProtocolGame() then if intentos < 40 then scheduleEvent(function() entrar(nombre, sesion, intentos + 1) end, 150) end return end if CharacterList and CharacterList.hide then pcall(CharacterList.hide) end if EnterGame and EnterGame.hide then pcall(EnterGame.hide) end local u = ultimo loginOriginal(u.account, u.password, u.worldName, u.worldHost, u.worldPort, nombre, u.authenticatorToken, sesion) end local function volver() if not ultimo then estado, mira = 'nada', nil ocultarBarra() return end estado = 'volviendo' textoBarra('Volviendo a tu personaje...') scheduleEvent(function() entrar(ultimo.characterName, ultimo.sessionKey) end, 300) end -- La ventana de error del login (characterlist.lua) no sirve aquí: se dice en la barra y se vuelve solo. local function quitarErrorLogin() for _, w in ipairs(rootWidget:getChildren()) do local t = w.getText and w:getText() or '' if t == tr('Login Error') or t == tr('Connection Error') then w:destroy() end end end function bolaController:onInit() g_ui.importStyle('/game_viaje/valdren') g_ui.importStyle('bola') ventana = g_ui.createWidget('BolaVentana', rootWidget) ventana:hide() transmitirV = g_ui.createWidget('BolaTransmitir', rootWidget) transmitirV:hide() transmitirV.nombre.onEnter = anadir indicador = g_ui.createWidget('BolaIndicador', rootWidget) indicador:addAnchor(AnchorTop, 'parent', AnchorTop) indicador:addAnchor(AnchorRight, 'parent', AnchorRight) indicador:setMarginTop(8) indicador:setMarginRight(250) indicador:hide() indicador.onClick = function() toggleTransmitir() end barra = g_ui.createWidget('BolaBarra', rootWidget) barra:hide() barra.salir.onClick = function() if estado == 'mirando' or estado == 'esperando' then if g_game.isOnline() then g_game.safeLogout() else g_game.cancelLogin() volver() end end end ProtocolGame.registerExtendedOpcode(OPCODE, function(protocol, opcode, buffer) onOpcode(buffer) end) loginOriginal = g_game.loginWorld g_game.loginWorld = function(account, password, worldName, worldHost, worldPort, characterName, authenticatorToken, sessionKey, ...) if not (sessionKey or ''):find('^@livestream') then ultimo = { account = account, password = password, worldName = worldName, worldHost = worldHost, worldPort = worldPort, characterName = characterName, authenticatorToken = authenticatorToken, sessionKey = sessionKey } end return loginOriginal(account, password, worldName, worldHost, worldPort, characterName, authenticatorToken, sessionKey, ...) end local function fallo(mensaje) if estado == 'esperando' or estado == 'mirando' then local motivo = (type(mensaje) == 'string' and mensaje ~= '') and mensaje or 'La bola de cristal se nubló.' textoBarra(motivo, '#DC6B6B') scheduleEvent(quitarErrorLogin, 50) estado = 'volviendo' scheduleEvent(function() entrar(ultimo.characterName, ultimo.sessionKey) end, 2500) elseif estado == 'volviendo' then ocultarBarra() estado, mira = 'nada', nil end end connect(g_game, { onLoginError = fallo, onConnectionError = fallo }) end function onOpcode(buffer) local c, resto = buffer:match('^(%a)|?(.*)$') if c == 'I' then local a = partir(resto, '^') RETRASO, MAX, MAXP = tonumber(a[1]) or RETRASO, tonumber(a[2]) or MAX, tonumber(a[3]) or MAXP activo = true elseif c == 'L' then abrirVentana(resto) elseif c == 'V' then local nombre, ticket, retraso = resto:match('^(.-)%^(%x+)%^(%d+)$') if not nombre or not ultimo then return end mira = { nombre = nombre, ticket = ticket, retraso = tonumber(retraso) } estado = 'saliendo' cerrar() cerrarTransmitir() g_game.safeLogout() scheduleEvent(function() -- el servidor no dejó salir (p. ej. combate): se queda donde estaba if estado == 'saliendo' and g_game.isOnline() then estado, mira = 'nada', nil end end, 5000) elseif c == 'E' then local a = partir(resto, '^') tx.on, tx.modo, tx.n, tx.max, tx.nombres = a[1] == '1', a[2] or 'privada', tonumber(a[3]) or 0, tonumber(a[4]) or MAX, a[5] or '' if not tx.on then modoElegido = modoElegido or tx.modo end refrescarIndicador() refrescarTransmitir() if ventana and ventana:isVisible() then ventana.pie.transmitir:setText(tx.on and 'Mi transmisión' or 'Transmitir yo') end elseif c == 'K' then permitidos = partir(resto, '~') refrescarTransmitir() elseif c == 'A' or c == 'O' then local color = c == 'A' and '#DC6B6B' or '#9AA7AE' if ventana and ventana:isVisible() then estadoEn(ventana, resto, color) refrescarVentana() end if transmitirV and transmitirV:isVisible() and transmitirV.sub and c == 'A' then transmitirV.sub:setText(resto) transmitirV.sub:setColor('#DC6B6B') scheduleEvent(function() if transmitirV then transmitirV.sub:setColor('#9AA7AE') refrescarTransmitir() end end, 5000) end end end function bolaController:onGameStart() if estado == 'esperando' then estado = 'mirando' reloj() elseif estado == 'volviendo' then estado = 'nada' mira = nil ocultarBarra() end if estado == 'nada' then -- «I» va en el login, pero Canary no pasa por el login al reconectar un personaje que seguía dentro scheduleEvent(function() if g_game.isOnline() and estado == 'nada' then enviar('H|') end end, 1500) end end function bolaController:onGameEnd() tx.on, tx.n = false, 0 refrescarIndicador() cerrar() cerrarTransmitir() if estado == 'saliendo' and mira then estado = 'esperando' mira.desde = g_clock.millis() reloj() scheduleEvent(function() entrar(mira.nombre, '@livestream\n' .. mira.ticket) end, 300) elseif estado == 'mirando' then volver() end end function bolaController:onTerminate() ProtocolGame.unregisterExtendedOpcode(OPCODE) if loginOriginal then g_game.loginWorld = loginOriginal end ocultarBarra() if puntoEv then removeEvent(puntoEv) puntoEv = nil end for _, w in ipairs({ ventana, transmitirV, indicador, barra }) do if w then w:destroy() end end ventana, transmitirV, indicador, barra = nil, nil, nil, nil end --- Para el arnés de pruebas function depurar() return ('estado=%s mira=%s ultimo=%s activo=%s tx=%s/%s/%d filas=%d'):format(estado, mira and mira.nombre or '-', ultimo and ultimo.characterName or '-', tostring(activo), tostring(tx.on), tx.modo, tx.n, #filas) end