-- private variables local background local clientVersionLabel local bgEffectEvent = nil -- OT-MMO (27-oct): login «El Último Faro de noche», escena en capas de PixelLab (background.otui, CREDITOS.md). -- Todo se mide en píxeles del ARTE (480x270) y se dibuja a escala entera k con recorte, así no se ve borroso: -- k = el menor entero que cubre la ventana (x3 en 1366x768, x4 en 1920x1080). local ARTE_W, ARTE_H = 480, 270 local ACANT_X, ACANT_W = 240, 240 -- el acantilado va pegado a la derecha del arte local LINTERNA_X, LINTERNA_Y = 431, 25 -- centro de la linterna, medido en noche-acantilado.png (191, 25) + 240 local NUBES_Y, NUBES_W, NUBES_H = -4, 480, 96 -- la tira (nubes y olas) son 3 vueltas de VUELTA px: se desliza el recorte local VUELTA = 416 -- 28-oct: antes se recortaba hasta x+480 = 959 en una tira de 832 (fuera de la -- textura): en Windows (ANGLE/DirectX) el juego se cerraba al abrir local OLAS_Y, OLAS_H = 232, 40 local HAZ_L, HAZ_H = 320, 72 -- noche-haz.png: el origen es su borde derecho, a media altura local AURA = 36 local LOGO_W, LOGO_H = 114, 60 -- noche-logo.png local TICK = 40 -- 25 fps; las capas se mueven de píxel de arte en píxel de arte local GIRO_MS = 9000 -- una vuelta del haz local N_CENIZA = 28 local faroEvent = nil local k, origenX, origenY = 1, 0, 0 local enterGameWin = nil local ultimoAviso = 0 local cenizas = {} local entradaEvent = nil -- «Reducir movimiento»: el navegador lo escribe en /reducir-movimiento.txt (browser/shell.html, matchMedia). -- En escritorio no existe el archivo; g_settings 'login-faro-quieto' = true lo fuerza a mano en cualquier build. local function sinMovimiento() if g_settings.getBoolean('login-faro-quieto') then return true end local ok, txt = pcall(g_resources.readFileContents, '/reducir-movimiento.txt') return ok and type(txt) == 'string' and txt:sub(1, 1) == '1' end local function capa(id) return background:getChildById(id) end local function ponerEn(w, x, y, ancho, alto) w:setSize({ width = math.floor(ancho + 0.5), height = math.floor(alto + 0.5) }) w:setMarginLeft(math.floor(x + 0.5)) w:setMarginTop(math.floor(y + 0.5)) end -- Coloca una capa en coordenadas del arte (x, y, ancho, alto en píxeles de arte). local function enArte(w, x, y, ancho, alto) ponerEn(w, origenX + x * k, origenY + y * k, ancho * k, alto * k) end -- Ceniza: motas de 1 o 2 píxeles de arte que suben despacio hacia la izquierda (el viento viene del mar abierto). local COLORES_CENIZA = { '#C9C2B2', '#9AA7AE', '#E39A4F', '#C9C2B2', '#F0C27A' } local function nuevaMota(m, abajo) m.x = math.random() * ARTE_W m.y = abajo and (ARTE_H + math.random() * 20) or (math.random() * ARTE_H) m.vx = -(2 + math.random() * 5) -- píxeles de arte por segundo m.vy = -(3 + math.random() * 6) m.fase = math.random() * 6.28 m.tam = math.random() < 0.25 and 2 or 1 m.w:setBackgroundColor(COLORES_CENIZA[math.random(#COLORES_CENIZA)]) m.w:setSize({ width = m.tam * k, height = m.tam * k }) m.w:setOpacity(0.35 + math.random() * 0.5) end local function crearCeniza() local cont = capa('ceniza') if #cenizas > 0 or not cont then return end for i = 1, N_CENIZA do local w = g_ui.createWidget('UIWidget', cont) w:setPhantom(true) w:setFocusable(false) w:addAnchor(AnchorTop, 'parent', AnchorTop) w:addAnchor(AnchorLeft, 'parent', AnchorLeft) local m = { w = w } nuevaMota(m, false) cenizas[i] = m end end local function moverCeniza(dt, t) for _, m in ipairs(cenizas) do m.x = m.x + m.vx * dt m.y = m.y + m.vy * dt if m.y < -4 or m.x < -4 then nuevaMota(m, true) end local bx = m.x + 2.5 * math.sin(t / 1300 + m.fase) m.w:setMarginLeft(origenX + math.floor(bx) * k) m.w:setMarginTop(origenY + math.floor(m.y) * k) end end local function quieto() local izq = capa('hazIzq') izq:show() capa('hazDer'):hide() enArte(izq, LINTERNA_X - HAZ_L * 0.8, LINTERNA_Y - HAZ_H / 2, HAZ_L * 0.8, HAZ_H) izq:setOpacity(0.75) capa('aura'):setOpacity(0.85) capa('nubes'):setImageClip({ x = 120, y = 0, width = NUBES_W, height = NUBES_H }) capa('olas'):setImageClip({ x = 0, y = 0, width = ARTE_W, height = OLAS_H }) capa('ceniza'):hide() end local ultimoT = 0 local function fotograma() local t = g_clock.millis() local dt = math.min(0.2, (t - ultimoT) / 1000) ultimoT = t local dos = 2 * math.pi -- Haz: gira alrededor de la torre. Visto de lado se alarga hacia un lado, se acorta al mirar al jugador -- (destello de la linterna) y sale por el otro lado. local c = math.cos(dos * t / GIRO_MS) local largo = math.floor(HAZ_L * math.abs(c) + 0.5) local izq, der = capa('hazIzq'), capa('hazDer') if largo < 1 then -- 28-oct: de frente el haz mide 0; no se dibuja una imagen de 0 px izq:hide(); der:hide() elseif c >= 0 then der:hide(); izq:show() enArte(izq, LINTERNA_X - largo, LINTERNA_Y - HAZ_H / 2, largo, HAZ_H) izq:setOpacity(0.45 + 0.55 * c) else izq:hide(); der:show() enArte(der, LINTERNA_X, LINTERNA_Y - HAZ_H / 2, largo, HAZ_H) der:setOpacity(0.35 + 0.45 * -c) end local frente = 1 - math.abs(c) -- 1 cuando el haz mira al jugador capa('aura'):setOpacity(0.55 + 0.45 * frente ^ 3 + 0.08 * math.sin(dos * t / 1700)) -- Nubes (una vuelta en 160 s) y olas (en 40 s): el recorte avanza de píxel de arte en píxel de arte. local n = math.floor(t / (160000 / NUBES_W)) % VUELTA capa('nubes'):setImageClip({ x = n, y = 0, width = NUBES_W, height = NUBES_H }) local o = math.floor(t / (40000 / ARTE_W)) % VUELTA capa('olas'):setImageClip({ x = o, y = 0, width = ARTE_W, height = OLAS_H }) local sube = math.floor(math.sin(dos * t / 3200) + 0.5) -- las olas suben y bajan un píxel de arte capa('olas'):setMarginTop(origenY + (OLAS_Y + sube) * k) moverCeniza(dt, t) if t - ultimoAviso > 2000 then -- ¿cambió «reducir movimiento»? ultimoAviso = t if sinMovimiento() then pararFaro() quieto() end end end function pararFaro() if faroEvent then removeEvent(faroEvent) faroEvent = nil end end function arrancarFaro() pararFaro() if not background or not background:isVisible() then return end if sinMovimiento() then quieto() return end capa('ceniza'):show() crearCeniza() ultimoT = g_clock.millis() faroEvent = cycleEvent(fotograma, TICK) fotograma() end local function ventanaLogin() if enterGameWin and enterGameWin:isDestroyed() then enterGameWin = nil end if not enterGameWin then enterGameWin = rootWidget:getChildById('enterGame') if enterGameWin then connect(enterGameWin, { onVisibilityChange = colocarLogo, onGeometryChange = colocarLogo }) end end return enterGameWin end -- Logo encima de la ventana de login, a la misma escala que el arte; el grupo (logo + ventana) queda centrado -- entre la luna y el faro. La ventana baja medio bloque con margin-top (con centerIn, el margen desplaza el centro). function colocarLogo() if not background then return end local logo, marca = capa('logo'), capa('marca') local win = ventanaLogin() local visible = win and win:isVisible() and background:isVisible() marca:hide() if not visible then -- sin la ventana de login (elegir personaje, crear cuenta): el logo pequeño arriba a la izquierda, -- dos escalas enteras por debajo de la del arte local e = math.max(1, k - 2) ponerEn(logo, 20, 14, LOGO_W * e, LOGO_H * e) logo:show() return end local W, H = background:getWidth(), background:getHeight() local alto = win:getHeight() local e = k while e > 1 and LOGO_H * e + 12 + alto + 32 > H do -- si no cabe, una escala entera menos e = e - 1 end local bloque = 0 if LOGO_H * e + 12 + alto + 32 <= H and e >= 2 then bloque = LOGO_H * e + 12 local arriba = (H - alto - bloque) / 2 ponerEn(logo, (W - LOGO_W * e) / 2, arriba, LOGO_W * e, LOGO_H * e) logo:show(); marca:hide() else ponerEn(logo, 20, 14, LOGO_W, LOGO_H) -- no cabe encima de la ventana: pequeño arriba a la izquierda logo:show() end local margen = math.floor(bloque / 2 + 0.5) if win:getMarginTop() ~= margen then win:setMarginTop(margen) end end function colocar() if not background then return end local W, H = background:getWidth(), background:getHeight() if W <= 0 or H <= 0 then return end k = math.max(1, math.ceil(math.max(W / ARTE_W, H / ARTE_H))) local anchoArte, altoArte = ARTE_W * k, ARTE_H * k -- Recorte horizontal: la linterna queda hacia el 80 % del ancho; si sobra, se corta por la izquierda (el mar). origenX = math.floor(math.max(W - anchoArte, math.min(0, W * 0.8 - LINTERNA_X * k))) origenY = math.floor((H - altoArte) * 0.25) -- en vertical sobra poco: se corta sobre todo el mar de abajo enArte(capa('cielo'), 0, 0, ARTE_W, ARTE_H) enArte(capa('nubes'), 0, NUBES_Y, NUBES_W, NUBES_H) enArte(capa('olas'), 0, OLAS_Y, ARTE_W, OLAS_H) enArte(capa('acantilado'), ACANT_X, 0, ACANT_W, ARTE_H) enArte(capa('aura'), LINTERNA_X - AURA / 2, LINTERNA_Y - AURA / 2, AURA, AURA) for _, m in ipairs(cenizas) do m.w:setSize({ width = m.tam * k, height = m.tam * k }) end if faroEvent then fotograma() else quieto() end colocarLogo() end -- Transición al entrar al juego: el haz del faro cruza la pantalla de derecha a izquierda mientras el velo se abre. -- 700 ms, salida exponencial. Sin «reducir movimiento» solo hay un fundido corto. local function pararEntrada(velo) if entradaEvent then removeEvent(entradaEvent) entradaEvent = nil end if velo and not velo:isDestroyed() then velo:destroy() end end function entrada(ms) -- ms: solo para probar la transición a cámara lenta if not rootWidget then return end local viejo = rootWidget:getChildById('faroEntrada') pararEntrada(viejo) local velo = g_ui.createWidget('UIWidget', rootWidget) velo:setId('faroEntrada') velo:setPhantom(true) velo:setFocusable(false) velo:fill('parent') velo:setBackgroundColor('#141A2E') local haz = g_ui.createWidget('UIWidget', velo) haz:setPhantom(true) haz:setImageSource('/images/login/noche-haz') haz:setImageSmooth(false) haz:addAnchor(AnchorTop, 'parent', AnchorTop) haz:addAnchor(AnchorLeft, 'parent', AnchorLeft) velo:raise() local W, H = rootWidget:getWidth(), rootWidget:getHeight() local DUR = ms or (sinMovimiento() and 300 or 700) local t0 = g_clock.millis() local function paso() if velo:isDestroyed() then return pararEntrada() end local p = math.min(1, (g_clock.millis() - t0) / DUR) local e = 1 - 2 ^ (-10 * p) -- ease-out-expo velo:setOpacity(1 - e) if sinMovimiento() then haz:hide() else local largo = W * 1.4 haz:setSize({ width = largo, height = H * 0.9 }) haz:setMarginTop(H * 0.05) haz:setMarginLeft(W - largo * (0.3 + 0.9 * e)) haz:setOpacity(1 - p) end if p >= 1 then pararEntrada(velo) end end paso() entradaEvent = cycleEvent(paso, 16) end -- public functions function init() background = g_ui.displayUI('background') background:lower() clientVersionLabel = background:getChildById('clientVersionLabel') clientVersionLabel:setText(g_app.getName() .. ' ' .. g_app.getVersion() .. '\n' .. 'Rev ' .. g_app.getBuildRevision() .. ' (' .. g_app.getBuildCommit() .. ')\n' .. 'Built on ' .. g_app.getBuildDate() .. '\n' .. g_app.getBuildCompiler() .. ' - ' .. g_app.getBuildArch()) if not g_game.isOnline() then addEvent(function() g_effects.fadeIn(clientVersionLabel, 1500) end) end connect(g_game, { onGameStart = alEntrar }) connect(g_game, { onGameEnd = show }) background.onGeometryChange = colocar addEvent(colocar) -- la ventana de login (client_entergame) se crea después de este módulo startBackgroundEffectLoop() -- start the background effect loop arrancarFaro() if g_game.isOnline() then hide() end end function terminate() disconnect(g_game, { onGameStart = alEntrar }) disconnect(g_game, { onGameEnd = show }) pararFaro() pararEntrada(rootWidget and rootWidget:getChildById('faroEntrada')) if enterGameWin and not enterGameWin:isDestroyed() then disconnect(enterGameWin, { onVisibilityChange = colocarLogo, onGeometryChange = colocarLogo }) enterGameWin:setMarginTop(0) end enterGameWin = nil cenizas = {} g_effects.cancelFade(background:getChildById('clientVersionLabel')) if bgEffectEvent then removeEvent(bgEffectEvent) bgEffectEvent = nil end background:destroy() background = nil clientVersionLabel = nil end function alEntrar() hide() entrada() end function hide() background:hide() pararFaro() -- en el juego no se anima nada del login if bgEffectEvent then removeEvent(bgEffectEvent) bgEffectEvent = nil end end function show() background:show() startBackgroundEffectLoop() colocar() arrancarFaro() end function hideVersionLabel() background:getChildById('clientVersionLabel'):hide() end function setVersionText(text) clientVersionLabel:setText(text) end function getBackground() return background end -- OT-MMO: las partículas del fondo original de OTClient siguen apagadas; la ceniza es parte de la escena (fotograma). function startBackgroundEffectLoop() if bgEffectEvent then removeEvent(bgEffectEvent) bgEffectEvent = nil end end