-- OT-MMO: fotos del juego (plan Portador de Luz, parte 4; vault OT-MMO/premium/plan-portador-y-casas.md). -- · Tecla «Hacer una foto» (Ctrl+Mayús+S; F con el chat apagado): captura la vista del juego SIN la interfaz (el -- framebuffer del mapa, g_app.doMapScreenshot) o, con la casilla, la pantalla entera (g_app.doScreenshot). Sale una -- vista previa con título opcional: «Guardar» pide al servidor un permiso de un solo uso (opcode 160 «T») y sube el -- PNG por HTTP al servicio de fotos (server/fotos/fotos.py), que la valida y la vuelve a codificar. -- · Marcos: el servidor (server/scripts/fotos/fotos.lua) dice qué foto lleva cada marco colgado (el «picture» 2639); -- este módulo busca los marcos que hay en pantalla, pregunta («Q») y pinta la foto encima como efecto adjunto con -- textura propia (g_attachedEffects.registerByImage, como los portales de game_viaje). La textura ya viene -- deformada para la pared desde el servicio (gancho al sur «s» o al este «e»). -- · Ventanas: «Elegir foto» (al usar un marco de tu casa), «Ver foto» (al usar o mirar el marco de otro). -- Las descargas también van por POST: en el cliente web solo vuelven las respuestas de POST (protocolhttp.cpp). -- Opcode 160: ver la cabecera de fotos.lua. local OPCODE = 160 local EFECTO0 = 3100 -- ids de efecto adjunto (los portales de game_viaje usan 960-971) local REPASO_MS = 1000 local CADUCA_MS = 120000 -- se vuelve a preguntar por un marco cada 2 min (los cambios llegan solos con «M») fotosController = Controller:new() local activo, URL, MARCO, LIMITE = false, '', 2639, 30 local vista, elegir, visor = nil, nil, nil local captura = nil -- { ruta, n } local numCaptura = 0 local subiendo = false local repasoEv = nil local cache = {} -- 'x,y,z' -> { id, clave, titulo, autor, t } local pedido = {} -- 'x,y,z' -> momento de la última pregunta local puestos = {} -- 'x,y,z' -> id de efecto pegado local efectos = {} -- clave .. v -> id de efecto registrado local sigEfecto = EFECTO0 local bajando = {} -- clave .. v -> { callbacks } local lista = {} -- última «L» local listaPos = nil -- marco que se está editando (o nil: solo «Mis fotos») local listaActual = 0 local elegida = nil -- id elegido en la lista 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 clavePos(p) return p.x .. ',' .. p.y .. ',' .. p.z 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) elseif modules.game_textmessage and modules.game_textmessage.displayFailureMessage then modules.game_textmessage.displayFailureMessage(texto) end end local function escribiendo() local w = rootWidget while w:getFocusedChild() do w = w:getFocusedChild() end return w:getClassName() == 'UITextEdit' and w:isVisible() end local function codificarUrl(s) return (s:gsub('[^%w%-%._~ ]', function(c) return string.format('%%%02X', c:byte()) end):gsub(' ', '+')) end -- POST con tiempo propio (HTTP.post usa 2 s: poco para subir 1 MB). La respuesta llega cruda: (datos, error). local function post(url, datos, segundos, cb) local op = g_http.post(url, datos, segundos, false) if not op or op < 0 then return cb(nil, 'sin conexión') end HTTP.operations[op] = { type = 'post', url = url, callback = cb } end -- -- Descargas (textura del marco y vista grande) ------------------------------------------------------------------- local function rutaFoto(clave, v) return '/fotos/' .. clave .. '-' .. v .. '.png' end local function bajar(clave, v, cb) local ruta = rutaFoto(clave, v) if g_resources.fileExists(ruta) then return cb(ruta) end local k = clave .. v if bajando[k] then table.insert(bajando[k], cb) return end bajando[k] = { cb } post(URL .. '/img/' .. clave .. '-' .. v .. '.png', 'v=' .. v, 20, function(datos, err) local cbs = bajando[k] or {} bajando[k] = nil local ok = not err and datos and datos:sub(1, 8) == '\137PNG\r\n\26\n' if ok then pcall(g_resources.makeDir, '/fotos') ok = g_resources.writeFileContents(ruta, datos) end for _, f in ipairs(cbs) do f(ok and ruta or nil) end end) end -- -- Marcos en la pared --------------------------------------------------------------------------------------------- local function gancho(pos) local tile = g_map.getTile(pos) if not tile then return 's' end for _, it in ipairs(tile:getItems() or {}) do if it:getId() ~= MARCO then local tt = g_things.getThingType(it:getId(), ThingCategoryItem) if tt and tt:isHookEast() then return 'e' end if tt and tt:isHookSouth() then return 's' end end end return 's' end local function marcosEn(pos) local t = {} local tile = g_map.getTile(pos) for _, it in ipairs(tile and tile:getItems() or {}) do if it:getId() == MARCO then t[#t + 1] = it end end return t end local function quitar(k, pos) if not puestos[k] then return end for _, it in ipairs(marcosEn(pos)) do it:detachEffectById(puestos[k]) end puestos[k] = nil end local function efectoDe(clave, v, ruta) local k = clave .. v if efectos[k] then return efectos[k] end local id = sigEfecto local ok, e = pcall(function() return g_attachedEffects.registerByImage(id, 'Foto ' .. k, ruta, true) end) if not ok or not e then return nil end sigEfecto = sigEfecto + 1 -- textura de 4x sobre el objeto de 64x64 (fotos.py, pared()): mismo origen que el sprite del marco e:setSize({ width = 64, height = 64 }) e:setOffset(32, 32) e:setPermanent(true) e:setOnTop(true) -- encima del sprite del marco (si no, el cuadro original tapa la foto) efectos[k] = id return id end local function pintar(item, pos, c) local k = clavePos(pos) local v = gancho(pos) local ruta = rutaFoto(c.clave, v) if not g_resources.fileExists(ruta) then return bajar(c.clave, v, function() end) -- el siguiente repaso la pega end local id = efectoDe(c.clave, v, ruta) if not id then return end if puestos[k] and puestos[k] ~= id then quitar(k, pos) end if not item:getAttachedEffectById(id) then local e = g_attachedEffects.getById(id) if e then item:attachEffect(e) end end puestos[k] = id end local function repasar() repasoEv = nil if not activo or not g_game.isOnline() then return end repasoEv = scheduleEvent(repasar, REPASO_MS) local yo = g_game.getLocalPlayer() local p = yo and yo:getPosition() if not p then return end local ok, hallados = pcall(g_map.findItemsById, MARCO, 80) if not ok or type(hallados) ~= 'table' then return end local t, preguntar = ahora(), {} for pos, item in pairs(hallados) do if pos.z == p.z and math.abs(pos.x - p.x) <= 11 and math.abs(pos.y - p.y) <= 9 then local k = clavePos(pos) local c = cache[k] if (not c or t - c.t > CADUCA_MS) and (not pedido[k] or t - pedido[k] > 5000) and #preguntar < 40 then pedido[k] = t preguntar[#preguntar + 1] = pos.x .. '^' .. pos.y .. '^' .. pos.z end if c and c.id > 0 then pintar(item, pos, c) end end end if #preguntar > 0 then enviar('Q|' .. table.concat(preguntar, '~')) end end local function leerMarcos(resto) local t = ahora() for _, l in ipairs(partir(resto, '~')) do local f = partir(l, '^') local x, y, z, id = tonumber(f[1]), tonumber(f[2]), tonumber(f[3]), tonumber(f[4]) or 0 if x and y and z then local pos = { x = x, y = y, z = z } local k = clavePos(pos) local antes = cache[k] cache[k] = { id = id, clave = f[5] or '', titulo = f[6] or '', autor = f[7] or '', t = t } if id == 0 or (antes and antes.clave ~= cache[k].clave) then quitar(k, pos) end if id > 0 then for _, it in ipairs(marcosEn(pos)) do pintar(it, pos, cache[k]) end end end end end -- -- Vista grande --------------------------------------------------------------------------------------------------- local function cerrarVisor() if visor then visor:hide() end end local function abrirVisor(clave, titulo, autor, fecha) if not visor then return end visor:setText(titulo ~= '' and titulo or 'Foto sin título') visor.pie:setText('Foto de ' .. autor .. (fecha ~= '' and (' · ' .. fecha) or '')) visor.imagen:setImageSource('') visor.cargando:show() visor:show() visor:raise() visor:focus() bajar(clave, 'g', function(ruta) if not visor or not visor:isVisible() then return end visor.cargando:setVisible(ruta == nil) visor.cargando:setText(ruta and '' or 'No se pudo cargar la foto.') if ruta then visor.imagen:setImageSource(ruta) end end) end -- -- Hacer la foto -------------------------------------------------------------------------------------------------- local function borrarCaptura() if captura then pcall(g_resources.deleteFile, captura.ruta) end captura = nil end local function estado(texto, color) if vista then vista.estado:setText(texto or '') vista.estado:setColor(color or '#9AA7AE') -- el estado va donde la nota: con un mensaje, la nota se esconde (no caben los dos) vista.nota:setVisible(not texto or texto == '') end end local function cerrarVista() borrarCaptura() subiendo = false if vista then vista:hide() end end -- En el navegador el motor no puede leer la pantalla ya pintada (el lienzo WebGL se borra al presentarse: sale negra), -- así que allí solo hay foto sin interfaz (el framebuffer del mapa sí se lee). En Windows, las dos. local function enNavegador() return g_platform.getOSName() == 'browser' end local function conInterfaz() return not enNavegador() and g_settings.getBoolean('fotos/interfaz', false) end -- El motor escribe el PNG en otra tarea: se espera a que termine (cierra con el bloque IEND). local function esperarPng(ruta, intentos, cb) if g_resources.fileExists(ruta) then local ok, d = pcall(g_resources.readFileContents, ruta) if ok and d and #d > 64 and d:sub(-8, -5) == 'IEND' then return cb(d) end end if intentos <= 0 then return cb(nil) end scheduleEvent(function() esperarPng(ruta, intentos - 1, cb) end, 100) end local function hacerFoto() if not activo then -- 31-oct: la configuración del login puede perderse; se pide y se reintenta una vez enviar('C') return scheduleEvent(function() if activo then hacerFoto() else aviso('Las fotos aún no están disponibles.') end end, 1200) end if not g_game.isOnline() or subiendo then return end local abierta = vista and vista:isVisible() if vista then vista:hide() end borrarCaptura() numCaptura = numCaptura + 1 pcall(g_resources.makeDir, '/fotos') -- nombre nuevo cada vez: la caché de texturas guarda la imagen por ruta local ruta = '/fotos/captura-' .. g_clock.millis() .. '-' .. numCaptura .. '.png' -- un fotograma sin la ventana antes de capturar (al repetir la foto) scheduleEvent(function() if conInterfaz() then g_app.doScreenshot(ruta) else g_app.doMapScreenshot(ruta) end esperarPng(ruta, 40, function(datos) if not datos then return aviso('No se pudo hacer la foto.') end captura = { ruta = ruta, bytes = #datos } if not vista then return end vista.imagen:setImageSource(ruta) vista.interfaz:setChecked(conInterfaz()) vista.interfaz:setVisible(not enNavegador()) if not abierta then vista.titulo:setText('') end estado(nil) vista.guardar:setEnabled(true) vista.guardar:setText('Guardar') vista:show() vista:raise() vista:focus() vista.titulo:focus() end) end, abierta and 80 or 10) end local function guardar() if not captura or subiendo then return end subiendo = true vista.guardar:setEnabled(false) vista.guardar:setText('Guardando...') estado('Pidiendo permiso al servidor...') enviar('T') end local function subir(permiso) if not subiendo or not captura or not vista then return end local ok, datos = pcall(g_resources.readFileContents, captura.ruta) if not ok or not datos or #datos == 0 then subiendo = false vista.guardar:setEnabled(true) vista.guardar:setText('Guardar') return estado('No se encuentra la foto. Hazla otra vez.', '#DC6B6B') end estado('Subiendo la foto...') local titulo = vista.titulo:getText():gsub('^%s+', ''):gsub('%s+$', '') local url = URL .. '/subir?t=' .. permiso .. '&titulo=' .. codificarUrl(titulo) post(url, datos, 60, function(resp, err) subiendo = false if not vista then return end local r = nil if resp and resp ~= '' then local okj, j = pcall(json.decode, resp) if okj and type(j) == 'table' then r = j end end if r and r.ok then borrarCaptura() vista:hide() aviso(r.mensaje or 'Foto guardada.') return end vista.guardar:setEnabled(true) vista.guardar:setText('Guardar') estado(r and r.mensaje or 'No se pudo subir la foto. Prueba otra vez en un momento.', '#DC6B6B') end) end -- -- Elegir foto (marco de tu casa) y «Mis fotos» ------------------------------------------------------------------- local ESTADOS = { [0] = 'Pendiente de revisar', [1] = 'En la galería', [2] = 'No publicada' } local COLORES = { [0] = '#D99A45', [1] = '#52A674', [2] = '#DC6B6B' } local function filaElegida() for _, f in ipairs(lista) do if f.id == elegida then return f end end end local function pintarDetalle() if not elegir then return end local f = filaElegida() local d = elegir.detalle d.vacio:setVisible(f == nil) d.imagen:setImageSource('') d.titulo:setText(f and (f.titulo ~= '' and f.titulo or 'Sin título') or '') d.nota:setText('') if f then local txt = ESTADOS[f.estado] or '' if f.estado == 1 then txt = txt .. ' · ' .. f.votos .. (f.votos == 1 and ' voto' or ' votos') end if f.estado == 2 and f.motivo ~= '' then txt = txt .. ': ' .. f.motivo end d.nota:setText(f.fecha .. ' · ' .. txt) d.nota:setColor(COLORES[f.estado] or '#9AA7AE') if f.estado ~= 2 then local id = f.id bajar(f.clave, 'g', function(ruta) if elegir and elegida == id and ruta then elegir.detalle.imagen:setImageSource(ruta) end end) end end local enMarco = listaPos ~= nil elegir.colgar:setVisible(enMarco) elegir.quitar:setVisible(enMarco) elegir.colgar:setEnabled(f ~= nil and f.estado ~= 2 and f.id ~= listaActual) elegir.colgar:setText(f and f.id == listaActual and 'Ya está colgada' or 'Colgar en el marco') elegir.quitar:setEnabled(listaActual > 0) elegir.borrar:setEnabled(f ~= nil) elegir.borrar:setText('Borrar') elegir.borrar.confirmar = nil end local function pintarLista() if not elegir then return end local cont = elegir.lista cont:destroyChildren() elegir.cuantas:setText(#lista .. ' de ' .. LIMITE) elegir.sinFotos:setVisible(#lista == 0) for _, f in ipairs(lista) do local w = g_ui.createWidget('FotoFila', cont) w.titulo:setText(f.titulo ~= '' and f.titulo or 'Sin título') w.sub:setText(f.fecha .. ' · ' .. (ESTADOS[f.estado] or '')) w.punto:setBackgroundColor(COLORES[f.estado] or '#9AA7AE') w.colgada:setVisible(f.id == listaActual) w:setOn(f.id == elegida) w.onClick = function() elegida = f.id for _, c in ipairs(cont:getChildren()) do c:setOn(c == w) end pintarDetalle() end end pintarDetalle() end local function leerLista(resto) local pos, actual, filas = resto:match('^([^|]*)|([^|]*)|(.*)$') if not pos then return end local p = partir(pos, '^') listaPos = (pos ~= '' and tonumber(p[3])) and { x = tonumber(p[1]), y = tonumber(p[2]), z = tonumber(p[3]) } or nil listaActual = tonumber(actual) or 0 lista = {} for _, l in ipairs(partir(filas, '~')) do local f = partir(l, '^') if tonumber(f[1]) then lista[#lista + 1] = { id = tonumber(f[1]), clave = f[2] or '', titulo = f[3] or '', estado = tonumber(f[4]) or 0, fecha = f[5] or '', motivo = f[6] or '', votos = tonumber(f[7]) or 0 } end end local sigue = false for _, f in ipairs(lista) do if f.id == elegida then sigue = true end end if not sigue then elegida = listaActual > 0 and listaActual or (lista[1] and lista[1].id) end if not elegir then return end elegir:setText(listaPos and 'Elegir foto para el marco' or 'Mis fotos') pintarLista() elegir:show() elegir:raise() elegir:focus() end -- -- Opcode --------------------------------------------------------------------------------------------------------- local function onOpcode(protocol, opcode, buffer) local c, resto = buffer:sub(1, 1), buffer:sub(3) if c == 'C' then local f = partir(resto, '^') URL, MARCO, LIMITE = f[1] or '', tonumber(f[2]) or 2639, tonumber(f[3]) or 30 activo = URL ~= '' if activo and not repasoEv then repasoEv = scheduleEvent(repasar, 1500) end elseif c == 'T' then subir(resto) elseif c == 'L' then leerLista(resto) elseif c == 'M' then leerMarcos(resto) elseif c == 'V' then local f = partir(resto, '^') abrirVisor(f[1] or '', f[2] or '', f[3] or '', f[4] or '') elseif c == 'A' then if subiendo and vista and vista:isVisible() then subiendo = false vista.guardar:setEnabled(true) vista.guardar:setText('Guardar') estado(resto, '#DC6B6B') else aviso(resto) end end end -- -- API para los botones del .otui --------------------------------------------------------------------------------- function guardarFoto() guardar() end function descartarFoto() cerrarVista() end function repetirFoto(conUi) g_settings.set('fotos/interfaz', conUi and true or false) if vista and vista:isVisible() and not subiendo then hacerFoto() end end function misFotos() enviar('L') end function cerrarElegir() if elegir then elegir:hide() end end function colgarElegida() if listaPos and elegida then enviar(('P|%d^%d^%d^%d'):format(listaPos.x, listaPos.y, listaPos.z, elegida)) end end function quitarDelMarco() if listaPos then enviar(('P|%d^%d^%d^0'):format(listaPos.x, listaPos.y, listaPos.z)) end end function borrarElegida() if not elegir or not elegida then return end local b = elegir.borrar if not b.confirmar then b.confirmar = true b:setText('¿Seguro? Borrar') return end enviar('D|' .. elegida) end function cerrarVisorFoto() cerrarVisor() end function hacer() hacerFoto() end function fotosController:onInit() -- capturas de otra sesión que no se guardaron for _, f in ipairs(g_resources.listDirectoryFiles('/fotos') or {}) do if f:find('^captura%-') then pcall(g_resources.deleteFile, '/fotos/' .. f) end end g_ui.importStyle('/game_viaje/valdren') g_ui.importStyle('fotos') vista = g_ui.createWidget('FotoVistaPrevia', rootWidget) vista:hide() elegir = g_ui.createWidget('FotoElegir', rootWidget) elegir:hide() visor = g_ui.createWidget('FotoVisor', rootWidget) visor:hide() vista.titulo.onEnter = guardar ProtocolGame.registerExtendedOpcode(OPCODE, onOpcode) Keybind.new('Fotos', 'Hacer una foto', { [CHAT_MODE.ON] = 'Ctrl+Shift+S', [CHAT_MODE.OFF] = 'F' }, '') Keybind.bind('Fotos', 'Hacer una foto', { { type = KEY_DOWN, callback = function() if not escribiendo() then hacerFoto() end end } }) end function fotosController:onGameStart() -- se enciende con «C» del servidor (Fotos.ACTIVAS); 31-oct: se pide también, porque la del login puede perderse scheduleEvent(function() if not activo and g_game.isOnline() then enviar('C') end end, 2500) end function fotosController:onGameEnd() activo = false if repasoEv then removeEvent(repasoEv) repasoEv = nil end cache, pedido, puestos = {}, {}, {} cerrarVista() if elegir then elegir:hide() end cerrarVisor() end function fotosController:onTerminate() self:onGameEnd() ProtocolGame.unregisterExtendedOpcode(OPCODE) Keybind.delete('Fotos', 'Hacer una foto') for _, w in ipairs({ vista, elegir, visor }) do if w then w:destroy() end end vista, elegir, visor = nil, nil, nil for _, id in pairs(efectos) do pcall(g_attachedEffects.remove, id) end efectos = {} end