-- OT-MMO: misiones estilo WoW. Solo dibuja y pide: el servidor (server/scripts/misiones/misiones.lua) decide qué -- misión ofrece cada NPC, cuenta el progreso y da las recompensas. Opcode extendido 111 (JSON): -- «E|{activas, marcas}» estado: misiones en curso y marcas por NPC («!» tiene misión, «?» puedes entregar) -- «V|{…}» abre la ventana de una misión (modo ofrecer, progreso o entregar) · «M|texto» aviso -- El cliente envía «P» (pedir estado), «A|id» (aceptar), «C|id» (completar) y «X|id» (abandonar). -- 23-oct: seguimiento más visible, marcas de objetivos en el minimapa (se habían perdido al mover el minimapa al marco -- de game_hud) y ruta marcada hacia el objetivo de una misión (línea, flecha de borde y aviso de piso). -- Las marcas las pone este cliente sobre los NPC que ve: el estado es de cada jugador, así que no sirven los íconos de -- criatura del servidor, que ven todos. local OPCODE = 111 local IMAGEN = { ['!'] = '/modules/game_misiones/images/marca-mision', ['?'] = '/modules/game_misiones/images/marca-entrega' } -- Tokens (corelib/ui/theme.lua); rareza con los colores comunes de gamelib/items.lua (los mismos del tooltip). local T = OtmmoTheme.color local ORO, APAGADO, CENIZA = T.accent, T.text2, T.border local CAL = (ItemsDatabase and ItemsDatabase.calidadColor) or {} local CALIDAD = { [1] = { 'Poco común', CAL['1'] or T.ok }, [2] = { 'Rara', CAL['2'] or T.mana }, [3] = { 'Épica', CAL['3'] or T.accent } } local MAX_SEGUIDAS = 4 -- Dificultad por nivel, como WoW (misiones.lua: colorDe). Gris = da menos experiencia. local DIFICULTAD = { gris = T.text2, verde = T.ok, amarillo = T.xp, naranja = T.warn, rojo = T.errorText } misionesController = Controller:new() local ventana, diario, seguimiento = nil, nil, nil local listaNpc = nil local ventanaBase, detalleNpc, npcHost, npcController, npcDatos, npcDetalle = nil, nil, nil, nil, nil, nil local activas, marcas = {}, {} local abierta, elegida = nil, nil local premioElegido = nil -- n de la recompensa a elegir marcada en la ventana local plegado = false local ruta = nil -- id de la misión con la ruta marcada (ver «Ruta marcada», más abajo) local ciclo, cicloPos = nil, nil local rutaSuelo = {} local rutaClave, rutaPuntos, rutaRecalcular = nil, {}, 0 local function enviar(texto) local protocol = g_game.getProtocolGame() if protocol then protocol:sendExtendedOpcode(OPCODE, texto) end end -- Misiones dentro de la conversacion: el servidor sigue validando cada seleccion (N|idNpc, V|id, A|id, C|id). -- 23-oct (2): pestaña «Misiones» del diálogo (game_npctrade/templates/npc_dialogo.otui). El panel ocupa la caja de la -- consola (top 147, 363 x 291); la entrada de texto sigue visible y escribir vuelve a Conversación. Al saludar solo se -- abre solo si hay algo que hacer (aceptar o entregar) o si el jugador lo pidió; si no, el contador de la pestaña avisa. local NPC_CAJA = { top = 147, left = 16 } local npcPidio, npcAbrir = false, false local ORDEN_NPC = { entregar = 1, ofrecer = 2, progreso = 3 } local GRUPO_NPC = { entregar = 'Lista para entregar', ofrecer = 'Disponible', progreso = 'En curso' } local ESTADO_NPC = { entregar = 'lista para entregar', ofrecer = 'disponible', progreso = 'en curso' } local function accionables(datos) local n = 0 for _, m in ipairs(datos and datos.misiones or {}) do if m.modo ~= 'progreso' then n = n + 1 end end return n end local function avisarPestana(cual) if npcController and npcController.marcarPestana then npcController:marcarPestana(cual) end end function ocultarNpcMisiones() if npcHost and not npcHost:isDestroyed() then npcHost:hide() end if npcController and npcController.ui and not npcController.ui:isDestroyed() then local w = npcController:findWidget('#consoleBuffer') if w then w:show() if w.verticalScrollBar then w.verticalScrollBar:show() end end avisarPestana('conversacion') end if ventana == detalleNpc then ventana = ventanaBase end end function cerrarNpcMisiones() ocultarNpcMisiones() if npcHost and not npcHost:isDestroyed() then npcHost:destroy() end npcHost, detalleNpc, npcController, npcDatos, npcDetalle = nil, nil, nil, nil, nil npcPidio, npcAbrir = false, false ventana = ventanaBase or ventana end function solicitarNpcMisiones() npcPidio = true mostrarNpcMisiones() if npcController and npcController.creatureId then enviar('N|' .. npcController.creatureId) end end -- Lista por prioridad (entregar > disponibles > en curso): una fila GameRow por misión con su marca («!» o «?»; en -- curso, el «?» apagado), el nombre y el estado debajo. La usan la pestaña del NPC y la ventana de los tablones. local function pintarListaNpc(panel, datos) local orden = {} for i, m in ipairs(datos.misiones) do orden[#orden + 1] = { m = m, i = i } end table.sort(orden, function(a, b) local x, y = ORDEN_NPC[a.m.modo] or 9, ORDEN_NPC[b.m.modo] or 9 if x ~= y then return x < y end return a.i < b.i end) for _, e in ipairs(orden) do local m = e.m local f = g_ui.createWidget('MiFilaNpc', panel) f.nombre:setText(m.nombre) f.estado:setText(GRUPO_NPC[m.modo] or '') f.estado:setColor(m.modo == 'entregar' and ORO or APAGADO) f.marca:setImageSource('/modules/game_misiones/images/' .. (m.modo == 'ofrecer' and 'marca-mision' or 'marca-entrega')) f.marca:setOpacity(m.modo == 'progreso' and 0.4 or 1) f.nombre:setColor(m.modo == 'progreso' and APAGADO or T.text) f.onClick = function() npcPidio = true enviar('V|' .. m.id) end end end function mostrarNpcMisiones() local c = npcController if not c or not c.ui or c.ui:isDestroyed() then return end if not npcHost or npcHost:isDestroyed() then npcHost = g_ui.loadUI('npc_panel', c.ui) npcHost:addAnchor(AnchorTop, 'parent', AnchorTop) npcHost:addAnchor(AnchorLeft, 'parent', AnchorLeft) npcHost:setMarginTop(NPC_CAJA.top) npcHost:setMarginLeft(NPC_CAJA.left) detalleNpc = nil end local consola = c:findWidget('#consoleBuffer') if consola then consola:hide() if consola.verticalScrollBar then consola.verticalScrollBar:hide() end -- la barra del HTML va aparte end avisarPestana('misiones') npcHost:show() npcHost:raise() npcHost.scroll:hide() npcHost.detalleBar:hide() npcHost.volver:hide() npcHost.principal:hide() npcHost.lista:show() npcHost.listaBar:show() npcHost.lista:destroyChildren() if detalleNpc then detalleNpc:hide() end if ventana == detalleNpc then ventana = ventanaBase end if not npcDatos or npcDatos.npc ~= c.creatureName or #npcDatos.misiones == 0 then local w = g_ui.createWidget('GameParagraph', npcHost.lista) w:setText(npcDatos and npcDatos.npc == c.creatureName and (c.creatureName .. ' no tiene misiones para ti ahora.') or 'Buscando misiones...') w:setHeight(40) return end pintarListaNpc(npcHost.lista, npcDatos) end function onNpcDialog(c) if npcController and npcController ~= c then cerrarNpcMisiones() end npcController = c if npcDatos and npcDatos.npc ~= c.creatureName then npcDatos, npcDetalle = nil, nil end if npcHost and npcHost:isDestroyed() then npcHost, detalleNpc = nil, nil end if c.contarMisiones then c:contarMisiones(accionables(npcDatos)) end if npcDatos and npcAbrir then npcAbrir = false mostrarNpcMisiones() if npcDetalle then abrirVentana(npcDetalle) end elseif npcHost and npcHost:isVisible() then mostrarNpcMisiones() -- ya estaba en la pestaña: refresca (p. ej. tras aceptar o entregar) end end local function recibirNpcMisiones(d, detalle) if detalle then if not npcDatos or npcDatos.npc ~= d.npc then npcDatos = {npc=d.npc,misiones={}} end local found = false for _, m in ipairs(npcDatos.misiones) do if m.id == d.id then m.modo=d.modo found=true end end if not found then table.insert(npcDatos.misiones,{id=d.id,nombre=d.nombre,modo=d.modo}) end npcDetalle=d else npcDatos=d npcDetalle=nil end -- se abre si el jugador lo pidió (pestaña o fila) o, al saludar, si hay algo que aceptar o entregar npcAbrir = npcPidio or (detalle and d.modo ~= 'progreso') or (not detalle and accionables(d) > 0) npcPidio = false scheduleEvent(function() local c = modules.game_npctrade.controllerNpcTrader if c and c.ui and c.ui:isVisible() and c.creatureName == d.npc then onNpcDialog(c) elseif detalle then abrirVentana(d) -- tablones sin ventana de conversacion elseif d.npc:find('^Tabl') or (c and c:isLegacyMode()) then if listaNpc then listaNpc:destroy() end listaNpc = g_ui.displayUI('lista_npc') OtmmoVentanas.recordar(listaNpc, 'misiones-tablon') listaNpc:setText(d.npc) pintarListaNpc(listaNpc.lista, d) end end, 200) end -- Para game_hud (contador del botón de misiones): cuántas están listas para entregar. function getListas() local n = 0 for _, m in ipairs(activas) do if m.completa then n = n + 1 end end return n end -- Para game_mapa: marcas por NPC («!» / «?») function getMarcas() return marcas end local function porId(id) for _, m in ipairs(activas) do if m.id == id then return m end end end -- Marcas sobre los NPC ------------------------------------------------------------------------------------------------ -- Widgets de interfaz sobre el mapa (como el rótulo de crítico de game_impacto), no efectos adjuntos: un efecto -- pegado a la criatura lo tapa el muro de la casilla de arriba (Hermana Lys, junto al muro del templo). local capas = {} -- [idCriatura] = widget con la imagen de la marca local function quitarCapa(id) if capas[id] then capas[id]:destroy() capas[id] = nil end end -- Cada segundo: qué NPC a la vista llevan marca y cuál. local function pintarMarcas() local yo = g_game.getLocalPlayer() local mapa = modules.game_interface.getMapPanel() if not yo or not mapa then return end local vistos = {} for _, c in ipairs(g_map.getSpectators(yo:getPosition(), false)) do local quiere = c:isNpc() and marcas[c:getName()] if quiere and IMAGEN[quiere] then local id = c:getId() vistos[id] = true local w = capas[id] if not w then w = g_ui.createWidget('UIWidget', mapa) w:setPhantom(true) capas[id] = w end if w.simbolo ~= quiere then w:setImageSource(IMAGEN[quiere]) w.simbolo = quiere end end end for id in pairs(capas) do if not vistos[id] then quitarCapa(id) end end end -- Cada 40 ms: encima del nombre del NPC, al tamaño de la casilla y con un balanceo lento (se ve de reojo). local function colocarMarcas() local mapa = modules.game_interface.getMapPanel() if not mapa then return end local vaiven = math.sin(g_clock.millis() / 380) * 2 for id, w in pairs(capas) do -- tras un teletransporte el NPC sigue en la caché pero sin posición (nil): getTileRect(nil) lanzaba un error (en el -- cliente web, «Lua exception:» sin mensaje) cada 40 ms hasta que pintarMarcas quitaba la marca local c = g_map.getCreatureById(id) local p = c and c:getPosition() local r = p and mapa:getTileRect(p) if r and r.width > 0 then local ancho = math.floor(r.width * 0.3) -- era 0.6: el usuario la vio muy grande (22-oct) local alto = math.floor(ancho * 1.6) w:resize(ancho, alto) w:setPosition({ x = r.x + math.floor((r.width - ancho) / 2) - math.floor(r.width / 4), y = r.y - alto - math.floor(r.width * 0.5) + math.floor(vaiven) }) w:show() else w:hide() end end end -- L no debe abrir el diario mientras se escribe en otra caja (ventana de NPC, terminal), como I/B en game_hud. local function escribiendo() local w = rootWidget while w:getFocusedChild() do w = w:getFocusedChild() end return w:getClassName() == 'UITextEdit' and w:isVisible() end -- Objetivos (ventana y diario) ------------------------------------------------------------------------------------------ local function pintarObjetivos(panel, objetivos, conProgreso) panel:destroyChildren() for _, o in ipairs(objetivos) do local w = g_ui.createWidget('MiObjetivo', panel) local hecho = conProgreso and o.actual >= o.total w.texto:setText(o.texto) if o.total > 1 then w.cuenta:setText(conProgreso and (o.actual .. '/' .. o.total) or tostring(o.total)) end w.casilla:setBackgroundColor(hecho and ORO or '#00000000') w.casilla:setBorderColor(hecho and ORO or CENIZA) w.texto:setColor(hecho and APAGADO or T.text) w.cuenta:setColor(hecho and ORO or APAGADO) end end local function meta(d) return (d.cadena ~= '' and (d.cadena .. ' \183 ') or '') .. d.zona end -- Ventana de misión ---------------------------------------------------------------------------------------------------- function abrirVentana(d) if npcController and npcController.ui and npcController.ui:isVisible() and npcController.creatureName == d.npc then mostrarNpcMisiones() npcHost.lista:hide() npcHost.scroll:show() npcHost.detalleBar:show() npcHost.listaBar:hide() npcHost.volver:show() if not detalleNpc then detalleNpc = g_ui.loadUI('detalle_npc', npcHost.scroll) -- los botones van fijos al pie del panel (npc_panel.otui), fuera del scroll detalleNpc.principal = npcHost.principal detalleNpc.secundario = npcHost.volver end ventana = detalleNpc ventana:setWidth(333) -- 363 - relleno 16 - barra 12 - 2 ventana:breakAnchors() ventana:addAnchor(AnchorTop, 'parent', AnchorTop) ventana:addAnchor(AnchorLeft, 'parent', AnchorLeft) else ventana = ventanaBase or ventana end abierta = d if ventana ~= detalleNpc then ventana:setText(d.npc) end local integrada = ventana == detalleNpc and detalleNpc ~= nil ventana.meta:setText(meta(d) .. (d.nivel and (' \183 nivel ' .. d.nivel) or '') .. (d.repetible == 'diaria' and ' \183 diaria' or '') .. (integrada and ESTADO_NPC[d.modo] and (' \183 ' .. ESTADO_NPC[d.modo]) or '')) ventana.nombre:setText(d.nombre) ventana.nombre:setColor(DIFICULTAD[d.color] or T.text) ventana.texto:setText(d.texto) pintarObjetivos(ventana.objetivos, d.objetivos, d.modo ~= 'ofrecer') local r = d.recompensa local partes = {} if r.exp > 0 then table.insert(partes, r.exp .. ' de experiencia') end if r.oro > 0 then table.insert(partes, r.oro .. ' de oro') end ventana.exp:setText(table.concat(partes, ' \183 ')) ventana.premios:destroyChildren() for _, o in ipairs(r.objetos) do local w = g_ui.createWidget('MiPremio', ventana.premios) w.marco.item:setItemId(o.id) if o.n > 1 then w.marco.item:setItemCount(o.n) end local c = CALIDAD[o.calidad or 0] w.nombre:setText((o.n > 1 and (o.n .. ' \215 ') or '') .. o.nombre .. (c and ('\n' .. c[1]) or '')) if c then w.marco:setBorderColor(c[2]) end end -- a elegir (como WoW): se marca una con clic; solo al entregar hace falta haber elegido premioElegido = nil local opciones = r.elegir or {} if #opciones > 0 then local t = g_ui.createWidget('GameSection', ventana.premios) t:setText(d.modo == 'entregar' and 'Elige una' or 'Podr\225s elegir una') local marcos = {} for _, o in ipairs(opciones) do local w = g_ui.createWidget('MiPremio', ventana.premios) w.marco.item:setItemId(o.id) local c = CALIDAD[o.calidad or 0] w.nombre:setText(o.nombre .. (c and ('\n' .. c[1]) or '')) local borde = c and c[2] or CENIZA w.marco:setBorderColor(borde) table.insert(marcos, { w = w, borde = borde }) if d.modo == 'entregar' then w:setPhantom(false) -- Panel y sus hijos dejan pasar el clic por defecto w.marco:setPhantom(true) w.nombre:setPhantom(true) w.onClick = function() premioElegido = o.n for _, mk in ipairs(marcos) do mk.w.marco:setBorderWidth(mk.w == w and 2 or 1) mk.w.marco:setBorderColor(mk.w == w and ORO or mk.borde) mk.w:setBackgroundColor(mk.w == w and T.surface or '#00000000') end end end end end local hayPremio = #partes > 0 or #r.objetos > 0 or #opciones > 0 ventana.tRecompensa:setVisible(hayPremio) ventana.exp:setVisible(hayPremio) if integrada then -- en la pestaña: «Volver» a la lista siempre a la izquierda; la acción (Aceptar / Entregar) a la derecha ventana.secundario:setText('Volver') ventana.secundario:show() ventana.principal:setVisible(d.modo ~= 'progreso') ventana.principal:setText(d.modo == 'entregar' and 'Entregar' or 'Aceptar') elseif d.modo == 'ofrecer' then ventana.principal:setText('Aceptar') ventana.secundario:setText('Rechazar') ventana.secundario:show() elseif d.modo == 'entregar' then ventana.principal:setText('Completar') ventana.secundario:setText('M\225s tarde') ventana.secundario:show() else ventana.principal:setText('Entendido') ventana.secundario:hide() end ventana:show() ventana:raise() if not integrada then ventana:focus() end -- en el diálogo, el foco sigue en la entrada de texto -- la altura sigue al contenido: el texto y los premios varían mucho entre misiones scheduleEvent(function() if not ventana or not ventana:isVisible() then return end local abajo = (hayPremio and ventana.premios or ventana.objetivos) local fin = abajo:getY() + abajo:getHeight() - ventana:getY() if ventana == detalleNpc then ventana:setHeight(fin + 4) -- los botones no van dentro: el scroll solo aparece si el texto no cabe else ventana:setHeight(math.max(300, fin + 26 + 22 + ventana:getPaddingBottom())) end end, 50) scheduleEvent(function() -- el diálogo del NPC se abre justo después del saludo if ventana and ventana:isVisible() then ventana:raise() end end, 150) end function cerrarVentana() local integrada = ventana == detalleNpc and detalleNpc ~= nil abierta = nil if ventana then ventana:hide() end if integrada then mostrarNpcMisiones() if npcController and npcController.enfocarEntrada then npcController:enfocarEntrada() end end end function principal() if abierta and abierta.modo == 'ofrecer' then enviar('A|' .. abierta.id) elseif abierta and abierta.modo == 'entregar' then if #(abierta.recompensa.elegir or {}) > 0 then if not premioElegido then modules.game_textmessage.displayFailureMessage('Elige una recompensa.') return end enviar('C|' .. abierta.id .. '|' .. premioElegido) else enviar('C|' .. abierta.id) end end cerrarVentana() end -- Diario (L) ----------------------------------------------------------------------------------------------------------- local function pintarDiario() diario.lista:destroyChildren() local hay = #activas > 0 diario.vacio:setVisible(not hay) diario.detalle:setVisible(hay) diario.separador:setVisible(hay) if not hay then diario.vacio:setText('No llevas misiones. Busca a quien tenga un \171!\187 dorado sobre la cabeza.') return end if not porId(elegida) then elegida = activas[1].id end for _, m in ipairs(activas) do local f = g_ui.createWidget('MiFila', diario.lista) f.nombre:setText(m.nombre) f.nombre:setColor(DIFICULTAD[m.color] or T.text) f.meta:setText(m.completa and ('Lista: entrega ' .. m.recibe) or m.zona) f.meta:setColor(m.completa and ORO or APAGADO) f:setChecked(m.id == elegida) f.onClick = function() elegida = m.id pintarDiario() end end local m = porId(elegida) local det = diario.detalle det.meta:setText(meta(m)) det.nombre:setText(m.nombre) det.texto:setText(m.texto) pintarObjetivos(det.objetivos, m.objetivos, true) det.entrega:setText('Se entrega ' .. m.recibe .. '.') det.entrega:setColor(m.completa and ORO or APAGADO) det.ruta:setText(m.id == ruta and 'Quitar ruta' or 'Marcar ruta') end function toggleDiario() if not diario then return end if diario:isVisible() then return diario:hide() end pintarDiario() diario:show() diario:raise() diario:focus() end function abandonar() if elegida then enviar('X|' .. elegida) end end -- Ruta marcada (23-oct) ------------------------------------------------------------------------------------------------- -- Una misión a la vez: línea desde el jugador hasta su objetivo más cercano en el minimapa de la esquina y en el mapa (M), -- flecha en el borde si queda fuera y aviso de piso (sube / baja) si está en otro. Se elige con un clic en el -- seguimiento, en una marca del mapa o en el diario; se recuerda por personaje y se quita al entregar o abandonar la -- misión (mientras falta entregarla, la ruta lleva a quien la recibe). local IMG_OBJ = '/modules/game_misiones/images/' local cicloRuta = nil local capasRuta = {} -- [widget minimapa] = { sombra, linea, flecha, piso } local filaRuta = nil -- línea «Ruta...» del bloque marcado en el seguimiento local atan2 = math.atan2 or math.atan local function claveRuta() local yo = g_game.getLocalPlayer() return yo and ('otmmo-ruta-' .. yo:getName()) or nil end function getActivas() return activas end function getRuta() return ruta end -- Objetivo más cercano de una misión: primero el mismo piso, luego la distancia. function destinoDe(m) local yo = g_game.getLocalPlayer() if not m or not yo then return nil end local p = yo:getPosition() local mejor, coste = nil, nil for _, d in ipairs(m.donde or {}) do local c = math.abs(d.z - p.z) * 100000 + math.max(math.abs(d.x - p.x), math.abs(d.y - p.y)) if not coste or c < coste then mejor, coste = d, c end end return mejor end local function textoPiso(dz) if dz == 0 then return nil end local n = math.abs(dz) return (dz < 0 and 'sube ' or 'baja ') .. n .. (n == 1 and ' piso' or ' pisos') end local function ocultarCapa(c) for _, w in pairs(c) do if not w:isDestroyed() then w:hide() end end end local function capaDe(mm) local c = capasRuta[mm] if c and not c.linea:isDestroyed() then return c end c = {} c.linea = g_ui.createWidget('MiRutaLinea') c.sombra = g_ui.createWidget('MiRutaSombra') mm:insertChild(1, c.linea) -- debajo de las marcas y de la cruz del jugador mm:insertChild(1, c.sombra) c.flecha = g_ui.createWidget('MiRutaFlecha', mm) c.piso = g_ui.createWidget('MiRutaPiso', mm) capasRuta[mm] = c return c end -- Un tramo recto de (x1, y1) a (x2, y2): un widget de ese largo girado alrededor de su centro. local function tramo(w, grosor, x1, y1, x2, y2) local dx, dy = x2 - x1, y2 - y1 local largo = math.sqrt(dx * dx + dy * dy) if largo < 3 then return w:hide() end w:setSize({ width = math.floor(largo + 0.5), height = grosor }) w:setPosition({ x = math.floor((x1 + x2) / 2 - largo / 2 + 0.5), y = math.floor((y1 + y2) / 2 - grosor / 2 + 0.5) }) w:setRotation(math.deg(atan2(dy, dx))) w:show() end -- Recorta el segmento a-b a la zona visible: círculo (minimapa de la esquina) o rectángulo (mapa M). -- Devuelve t0, t1 en [0, 1] o nil si no se ve nada. local function recortar(ax, ay, bx, by, zona) local dx, dy = bx - ax, by - ay if zona.r then local fx, fy = ax - zona.cx, ay - zona.cy local A = dx * dx + dy * dy local B = 2 * (fx * dx + fy * dy) local C = fx * fx + fy * fy - zona.r * zona.r if A == 0 then return (C <= 0) and 0 or nil, 0 end local disc = B * B - 4 * A * C if disc < 0 then return nil end local s = math.sqrt(disc) local t0, t1 = math.max(0, (-B - s) / (2 * A)), math.min(1, (-B + s) / (2 * A)) if t0 > t1 then return nil end return t0, t1 end local t0, t1 = 0, 1 for _, lado in ipairs({ { -dx, ax - zona.x0 }, { dx, zona.x1 - ax }, { -dy, ay - zona.y0 }, { dy, zona.y1 - ay } }) do local p, q = lado[1], lado[2] if p == 0 then if q < 0 then return nil end else local t = q / p if p < 0 then t0 = math.max(t0, t) else t1 = math.min(t1, t) end end end if t0 > t1 then return nil end return t0, t1 end -- Punto del borde de la zona en la dirección del centro hacia (bx, by). local function bordeHacia(bx, by, zona) local cx = zona.cx or (zona.x0 + zona.x1) / 2 local cy = zona.cy or (zona.y0 + zona.y1) / 2 local dx, dy = bx - cx, by - cy local l = math.sqrt(dx * dx + dy * dy) if l == 0 then return cx, cy end if zona.r then return cx + dx / l * zona.r, cy + dy / l * zona.r end local k = math.min((zona.x1 - cx) / math.max(math.abs(dx), 0.001), (zona.y1 - cy) / math.max(math.abs(dy), 0.001)) return cx + dx * k, cy + dy * k end local function centro(r) return r.x + r.width / 2, r.y + r.height / 2 end local function dibujarRuta(mm, redondo, d, yo) local c = capaDe(mm) local cam = mm:getCameraPosition() if not d or not cam or not mm:isVisible() then return ocultarCapa(c) end local ax, ay = centro(mm:getTileRect({ x = yo.x, y = yo.y, z = cam.z })) local bx, by = centro(mm:getTileRect({ x = d.x, y = d.y, z = cam.z })) local r = mm:getRect() local zona if redondo then zona = { cx = r.x + r.width / 2, cy = r.y + r.height / 2, r = r.width / 2 - 10 } else zona = { x0 = r.x + 10, y0 = r.y + 10, x1 = r.x + r.width - 10, y1 = r.y + r.height - 10 } end local t0, t1 = recortar(ax, ay, bx, by, zona) local dentro = t1 ~= nil and t1 >= 0.999 if t0 then local x1, y1 = ax + (bx - ax) * t0, ay + (by - ay) * t0 local x2, y2 = ax + (bx - ax) * t1, ay + (by - ay) * t1 tramo(c.sombra, 5, x1, y1, x2, y2) tramo(c.linea, 3, x1, y1, x2, y2) else c.sombra:hide() c.linea:hide() end -- fuera de la vista: flecha en el borde, girada hacia el objetivo local fx, fy = bx, by if dentro then c.flecha:hide() else if t0 then fx, fy = ax + (bx - ax) * t1, ay + (by - ay) * t1 else fx, fy = bordeHacia(bx, by, zona) end c.flecha:setPosition({ x = math.floor(fx - 8), y = math.floor(fy - 8) }) c.flecha:setRotation(math.deg(atan2(by - ay, bx - ax))) c.flecha:show() c.flecha:raise() end -- otro piso: flecha arriba o abajo junto al final de la ruta if d.z ~= yo.z then c.piso:setImageSource(IMG_OBJ .. (d.z < yo.z and 'piso-arriba' or 'piso-abajo')) c.piso:setPosition({ x = math.floor(fx + 6), y = math.floor(fy - 18) }) c.piso:show() c.piso:raise() else c.piso:hide() end end local PASOS = { [0] = {0,-1}, {1,0}, {0,1}, {-1,0}, {1,-1}, {1,1}, {-1,1}, {-1,-1} } local function pintarRutaSuelo(d, p) local mapa = modules.game_interface.getMapPanel() if not mapa then return end if not d or not p or d.z ~= p.z then for _, w in ipairs(rutaSuelo) do w:hide() end rutaClave = nil return end local key = table.concat({p.x,p.y,p.z,d.x,d.y,d.z}, ':') local now = g_clock.millis() if key ~= rutaClave and now >= rutaRecalcular then rutaClave, rutaPuntos, rutaRecalcular = key, {}, now + 500 -- End beside an NPC/monster if its own tile is occupied. local directions, result = g_map.findPath(p, {x=d.x,y=d.y,z=d.z}, 10000, 0) if result ~= PathFindResults.Ok then for _, offset in ipairs({ {0,1}, {1,0}, {0,-1}, {-1,0} }) do directions, result = g_map.findPath(p, {x=d.x+offset[1],y=d.y+offset[2],z=d.z}, 2500, 0) if result == PathFindResults.Ok then break end end end if result == PathFindResults.Ok and directions then local x, y = p.x, p.y for i, direction in ipairs(directions) do local step = PASOS[direction] if not step then break end x, y = x + step[1], y + step[2] if i <= 32 then rutaPuntos[#rutaPuntos + 1] = {x=x,y=y,z=p.z} end end end end local area = mapa:getRect() for i, pos in ipairs(rutaPuntos) do local w = rutaSuelo[i] if not w then w = g_ui.createWidget('UIWidget', mapa) w:setSize({width=6,height=6}) w:setBackgroundColor(T.accent) w:setBorderWidth(1) w:setBorderColor(T.bg) w:setPhantom(true) rutaSuelo[i] = w end local r = mapa:getTileRect(pos) local visible = r and r.width > 0 and r.x >= area.x and r.y >= area.y and r.x + r.width <= area.x + area.width and r.y + r.height <= area.y + area.height w:setVisible(visible == true) if visible then w:setPosition({x=r.x+r.width/2-3, y=r.y+r.height/2-3}) end end for i = #rutaPuntos + 1, #rutaSuelo do rutaSuelo[i]:hide() end end local function pintarRuta() local yo = g_game.getLocalPlayer() local m = ruta and porId(ruta) local d = m and destinoDe(m) local p = yo and yo:getPosition() pintarRutaSuelo(d, p) for k, mm in ipairs(minimapas()) do if d and p then dibujarRuta(mm, mm.esquina == true, d, p) elseif capasRuta[mm] then ocultarCapa(capasRuta[mm]) end end if filaRuta and not filaRuta:isDestroyed() then local piso = d and p and textoPiso(d.z - p.z) local texto = not d and 'Ruta marcada: sin lugar conocido' or piso and ('Ruta: ' .. piso) or 'Ruta marcada' if filaRuta:getText() ~= texto then filaRuta:setText(texto) end end end local function quitarCapasRuta() for mm, c in pairs(capasRuta) do for _, w in pairs(c) do if not w:isDestroyed() then w:destroy() end end end capasRuta = {} end local function arrancarCicloRuta() if cicloRuta then removeEvent(cicloRuta) cicloRuta = nil end if ruta then cicloRuta = cycleEvent(pintarRuta, 60) -- sigue al jugador, al zoom y al arrastre del mapa end pintarRuta() end -- Marca (o quita, si ya era esa) la ruta de una misión. function setRuta(id, forzar) if id and not forzar and id == ruta then id = nil end ruta = id local clave = claveRuta() if clave then g_settings.set(clave, ruta or '') end pintarSeguimiento() pintarZonas() arrancarCicloRuta() if modules.game_mapa and modules.game_mapa.pintarMisiones then modules.game_mapa.pintarMisiones() end end function rutaDesdeDiario() if elegida then setRuta(elegida) if diario and diario:isVisible() then pintarDiario() end end end -- Seguimiento en pantalla ---------------------------------------------------------------------------------------------- -- La misión con ruta va primera y destacada; las demás, en el orden del servidor (hasta MAX_SEGUIDAS). function pintarSeguimiento() if not seguimiento then return end for _, c in ipairs(seguimiento:getChildren()) do if c:getId() ~= 'titulo' then c:destroy() end end filaRuta = nil seguimiento:setVisible(#activas > 0) local cab = seguimiento.titulo cab.titulo:setText('Misiones') cab.cuenta:setText(tostring(#activas)) cab:setTooltip(plegado and 'Clic: mostrar las misiones' or 'Clic: plegar') if plegado then return end local orden = {} local conRuta = ruta and porId(ruta) if conRuta then table.insert(orden, conRuta) end for _, m in ipairs(activas) do if m ~= conRuta then table.insert(orden, m) end end for i, m in ipairs(orden) do if i > MAX_SEGUIDAS then break end local b = g_ui.createWidget('MiSegMision', seguimiento) local marcada = m.id == ruta b:setOn(marcada) b:setTooltip(marcada and 'Clic: quitar la ruta' or 'Clic: marcar la ruta en el mapa') b.onClick = function() setRuta(m.id) end local n = g_ui.createWidget('MiSegNombre', b) n:setText(m.nombre) n:setColor(m.completa and ORO or DIFICULTAD[m.color] or T.text) if m.completa then local l = g_ui.createWidget('MiSegObjetivo', b) l:setText('Entrega a ' .. m.recibe) l:setColor(ORO) l.casilla:setBackgroundColor(ORO) l.casilla:setBorderColor(ORO) else for _, o in ipairs(m.objetivos) do local hecho = o.actual >= o.total local l = g_ui.createWidget('MiSegObjetivo', b) l:setText(o.texto) l:setColor(hecho and APAGADO or T.text) if o.total > 1 then local cuenta = math.min(o.actual, o.total) .. '/' .. o.total l.cuenta:setText(cuenta) l.cuenta:setColor(hecho and T.ok or T.text) l:setTextOffset({ x = 14 + #cuenta * 7 + 6, y = 0 }) -- Geist Mono 12: unos 7 px por cifra end if hecho then l.casilla:setBackgroundColor(T.ok) l.casilla:setBorderColor(T.ok) end end end if marcada then filaRuta = g_ui.createWidget('MiSegRuta', b) end end if #orden > MAX_SEGUIDAS then local mas = g_ui.createWidget('GameTextSmall', seguimiento) mas:setMarginTop(6) mas:setText('y ' .. (#orden - MAX_SEGUIDAS) .. ' m\225s en el diario (L)') end pintarRuta() end function plegar() plegado = not plegado g_settings.set('misionesPlegado', plegado) pintarSeguimiento() end -- Opcode ----------------------------------------------------------------------------------------------------------------- local function onOpcode(protocol, opcode, buffer) local tipo, resto = buffer:sub(1, 1), buffer:sub(3) if tipo == 'M' then return modules.game_textmessage.displayStatusMessage(resto) end local ok, d = pcall(json.decode, resto) if not ok or type(d) ~= 'table' then return g_logger.error('[misiones] datos ilegibles: ' .. tostring(d)) end if tipo == 'E' then activas, marcas = d.activas or {}, d.marcas or {} if ruta and not porId(ruta) then -- entregada o abandonada: la ruta se quita sola ruta = nil local clave = claveRuta() if clave then g_settings.set(clave, '') end arrancarCicloRuta() end pintarSeguimiento() if diario:isVisible() then pintarDiario() end pintarMarcas() pintarZonas() if modules.game_mapa and modules.game_mapa.pintarMisiones then modules.game_mapa.pintarMisiones() end elseif tipo == 'L' then recibirNpcMisiones(d, false) elseif tipo == 'V' then recibirNpcMisiones(d, true) end end -- Marcas de objetivos en el minimapa de la esquina y en el mapa (M), como WoW. El servidor manda «donde» en cada misión -- activa (server/scripts/misiones/misiones.lua: dondeDe). Íconos: arte/iconos-objetivos/build.py (disco de acento de -- 19 px; la misión con ruta, 23 px con aro claro). Las zonas de caza y recogida, un círculo translúcido a su radio. -- 23-oct: el minimapa de la esquina ya no está dentro de «mainmappanel» (game_hud lo mueve al marco pizarra), así que -- se pide a game_minimap; antes no se encontraba y no salía ninguna marca. En el mapa (M) se pintan después de los -- lugares (game_mapa llama a pintarZonas) para que no queden debajo del ícono del NPC. local zonas = {} -- { w, mm, z, r, tam, redondo } local cicloZonas = nil local TIPO = { caza = 'Cazar', recoger = 'Recoger', llegar = 'Ir a', hablar = 'Hablar', entregar = 'Entregar' } function minimapas() local l = {} local m1 = modules.game_minimap and modules.game_minimap.getMiniMapUi and modules.game_minimap.getMiniMapUi() if m1 then m1.esquina = true table.insert(l, m1) end local m2 = modules.game_mapa and modules.game_mapa.getMapa and modules.game_mapa.getMapa() if m2 then table.insert(l, m2) end return l end local function quitarZonas() for _, z in ipairs(zonas) do z.w:destroy() end zonas = {} end function ajustarZonas() for _, z in ipairs(zonas) do local cam = z.mm:getCameraPosition() local ver = cam ~= nil and cam.z == z.z if ver and z.redondo then -- minimapa de la esquina: es redondo; lo que cae fuera del círculo no se dibuja local a, b = z.mm:getRect(), z.w:getRect() local dx = (b.x + b.width / 2) - (a.x + a.width / 2) local dy = (b.y + b.height / 2) - (a.y + a.height / 2) ver = dx * dx + dy * dy <= (a.width / 2) * (a.width / 2) end z.w:setVisible(ver) if ver and z.r > 0 then local px = math.max(16, math.floor((2 * z.r + 1) * z.mm:getScale())) if z.tam ~= px then z.tam = px z.w:setSize({ width = px, height = px }) end end end end function pintarZonas() quitarZonas() local orden = {} for _, m in ipairs(activas) do if m.id ~= ruta then table.insert(orden, m) end end local conRuta = ruta and porId(ruta) if conRuta then table.insert(orden, conRuta) -- la última: queda encima de las demás end for _, mm in ipairs(minimapas()) do mm:setClipping(true) -- lo que se sale del recuadro del minimapa no se dibuja local redondo = mm.esquina == true for _, m in ipairs(orden) do local marcada = m.id == ruta for _, d in ipairs(m.donde or {}) do local pos = { x = d.x, y = d.y, z = d.z } if d.r > 0 then local area = g_ui.createWidget('UIWidget') area:setImageSource(IMG_OBJ .. 'zona') area:setPhantom(true) area:setOpacity(marcada and 1 or 0.6) mm:insertChild(1, area) -- debajo de cruces, banderas y lugares mm:centerInPosition(area, pos) table.insert(zonas, { w = area, mm = mm, z = d.z, r = d.r, redondo = redondo }) end local icono = g_ui.createWidget('MiMarcaObjetivo', mm) icono:setImageSource(IMG_OBJ .. 'obj-' .. d.tipo .. (marcada and '-sel' or '')) local tam = marcada and 23 or 19 icono:setSize({ width = tam, height = tam }) icono:setTooltip((TIPO[d.tipo] or '') .. ': ' .. m.nombre .. '\n' .. (marcada and 'Clic: quitar la ruta' or 'Clic: marcar la ruta')) icono.onMouseRelease = function(self, mousePos, boton) if boton == MouseLeftButton then setRuta(m.id) return true end return false end mm:centerInPosition(icono, pos) table.insert(zonas, { w = icono, mm = mm, z = d.z, r = 0, redondo = redondo }) end end end ajustarZonas() -- la ruta va debajo de las marcas recién creadas for mm, c in pairs(capasRuta) do if not c.linea:isDestroyed() then mm:moveChildToIndex(c.sombra, 1) mm:moveChildToIndex(c.linea, 2) end end end -- Para probar sin servidor: modules.game_misiones.probar('E', '') function probar(tipo, texto) onOpcode(nil, OPCODE, tipo .. '|' .. texto) end function misionesController:onInit() g_ui.importStyle('estilos') ventana = g_ui.displayUI('ventana') ventanaBase = ventana OtmmoVentanas.recordar(ventana, 'mision') -- posición entre sesiones (fase 4) -- a la izquierda, como el marco de misión de WoW: el diálogo del NPC (centro) sigue a la vista ventana:breakAnchors() ventana:addAnchor(AnchorTop, 'parent', AnchorTop) ventana:addAnchor(AnchorLeft, 'parent', AnchorLeft) ventana:setMarginTop(90) ventana:setMarginLeft(24) ventana:hide() diario = g_ui.displayUI('diario') OtmmoVentanas.recordar(diario, 'diario') -- posición entre sesiones (fase 4) diario:hide() seguimiento = g_ui.loadUI('seguimiento', modules.game_interface.getRootPanel()) -- sitio provisional: game_hud lo pone a la izquierda del marco del minimapa al montar el HUD seguimiento:addAnchor(AnchorTop, 'parent', AnchorTop) seguimiento:addAnchor(AnchorRight, 'parent', AnchorRight) seguimiento:setMarginTop(8) seguimiento:setMarginRight(272) seguimiento:hide() OtmmoVentanas.moverCon(seguimiento, seguimiento.titulo, 'seguimiento') plegado = g_settings.getBoolean('misionesPlegado', false) ProtocolGame.registerExtendedOpcode(OPCODE, onOpcode) Keybind.new('Ventanas', 'Misiones', { [CHAT_MODE.ON] = '', [CHAT_MODE.OFF] = 'L' }, '') Keybind.bind('Ventanas', 'Misiones', { { type = KEY_DOWN, callback = function() if not escribiendo() then toggleDiario() end end } }) if g_game.isOnline() then self:onGameStart() end end function misionesController:onGameStart() if ciclo then removeEvent(ciclo) removeEvent(cicloPos) end ciclo = cycleEvent(pintarMarcas, 1000) cicloPos = cycleEvent(colocarMarcas, 40) if cicloZonas then removeEvent(cicloZonas) end cicloZonas = cycleEvent(ajustarZonas, 400) -- piso y zoom de cada minimapa local clave = claveRuta() local guardada = clave and g_settings.getString(clave) or '' ruta = guardada ~= '' and guardada or nil -- se confirma al llegar el estado (si ya no existe, se quita) arrancarCicloRuta() enviar('P') end function misionesController:onGameEnd() if ciclo then removeEvent(ciclo) removeEvent(cicloPos) ciclo, cicloPos = nil, nil end for id in pairs(capas) do quitarCapa(id) end if cicloZonas then removeEvent(cicloZonas) cicloZonas = nil end if cicloRuta then removeEvent(cicloRuta) cicloRuta = nil end quitarZonas() quitarCapasRuta() for _, w in ipairs(rutaSuelo) do w:destroy() end rutaSuelo, rutaPuntos, rutaClave = {}, {}, nil cerrarNpcMisiones() activas, marcas, ruta = {}, {}, nil cerrarVentana() if listaNpc then listaNpc:destroy() listaNpc = nil end if diario then diario:hide() end if seguimiento then seguimiento:hide() end end function misionesController:onTerminate() self:onGameEnd() ProtocolGame.unregisterExtendedOpcode(OPCODE) Keybind.delete('Ventanas', 'Misiones') for _, w in ipairs({ ventana, diario, seguimiento }) do if w then w:destroy() end end ventana, diario, seguimiento = nil, nil, nil end