-- OT-MMO: el Bardo (vocación 12, clientid 7). Canciones por ESTRATAGEMA: se mantiene Ctrl y se marcan 3-5 flechas; al -- soltar Ctrl suena la canción de esa secuencia. Solo dibuja y pide: el servidor (server/scripts/clases/bardo.lua) -- valida la secuencia, el nivel, el maná, la recarga y el ritmo, y decide si sale «Afinada». Opcode 137: -- cliente «F|dir|ms» flecha (dir U/R/D/L, ms desde la primera) · «S|ms,ms,...» suelta Ctrl · «X» cancela · «P» libro -- servidor «L|pulso|tolerancia|id^nombre^seq^nivel^maná^espera^rama^sabe^texto~...» · «R|1|nombre|afinada» · «R|0|motivo» -- · «A|n|ok» acuse de cada flecha -- Ctrl + flecha sigue girando al personaje (game_walk): este módulo solo ESCUCHA esas teclas, no las ata, así que el giro -- de las demás clases no cambia. Para el resto de clases no hace nada. local OPCODE = 137 local VOC_BARDO = 7 -- clientid de vocations.xml local C = OtmmoTheme.color local ORO = '#C7A86B' local FLECHAS = { [KeyUp] = 'U', [KeyRight] = 'R', [KeyDown] = 'D', [KeyLeft] = 'L' } local RAMAS = { cancion_guerra = 'Canción de Guerra', disonancia = 'Disonancia', himno = 'Himno' } local RAMAS_CORTAS = { cancion_guerra = 'Guerra', disonancia = 'Disonancia', himno = 'Himno' } -- la columna de la lista es estrecha local CLIP = { vacia = 0, llena = 34, atiempo = 68, fuera = 102, mal = 136 } local MAX = 5 bardoController = Controller:new() local hud, libro = nil, nil local pulso, tolerancia = 500, 110 local canciones = {} -- { id, nombre, seq, nivel, mana, espera, rama, sabe, texto } local marca = nil -- { flechas = { 'U', ... }, tiempos = { ms }, t0 } local esperando = false -- soltó Ctrl, falta la respuesta local ocultarEn, destelloEvento = nil, nil local ciclo = nil local elegida = nil local conectado = false 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 esBardo() local p = g_game.getLocalPlayer() return p and p:getVocation() == VOC_BARDO end local function ahora() return g_clock.millis() end -- Canciones ----------------------------------------------------------------------------------------------------------- -- La canción que suena con esa secuencia exacta (la de más nivel si hay dos: la «Gran» sustituye a la vieja). local function cancionDe(seq) local mejor for _, c in ipairs(canciones) do if c.seq == seq and c.sabe and (not mejor or c.nivel > mejor.nivel) then mejor = c end end return mejor end -- ¿Alguna canción que sabes empieza así? local function cabe(prefijo) for _, c in ipairs(canciones) do if c.sabe and c.seq:sub(1, #prefijo) == prefijo then return true end end return false end local function seqDe(flechas) return table.concat(flechas) end -- HUD ----------------------------------------------------------------------------------------------------------------- local function casilla(i) return hud.casillas:getChildByIndex(i) end local function pintarCasilla(i, estado, dir) local w = casilla(i) w:setImageClip({ x = 0, y = CLIP[estado], width = 34, height = 34 }) if dir then w.flecha:setImageSource('/modules/game_bardo/images/flecha-' .. dir) w.flecha:setVisible(true) w.flecha:setOpacity(estado == 'fuera' and 0.55 or 1) else w.flecha:setVisible(false) end end -- Juicio de cada nota (27-oct): «¡A tiempo!», «Pronto» o «Tarde» sube sobre su casilla y se desvanece. local juicioHasta, juicioY local function mostrarJuicio(i, err) if i == 1 then return end -- la primera marca el compás: siempre a tiempo local txt, col = 'Tarde', C.text if math.abs(err) <= tolerancia then txt, col = '¡A tiempo!', ORO elseif err < 0 then txt = 'Pronto' end local p = casilla(i):getPosition() hud.juicio:setText(txt) hud.juicio:setColor(col) hud.juicio:setOpacity(1) hud.juicio:setVisible(true) juicioY = p.y - 18 hud.juicio:setPosition({ x = p.x + 17 - math.floor(hud.juicio:getWidth() / 2), y = juicioY }) juicioHasta = ahora() + 600 end local BARRA = { vacia = 0, siguiente = 6, late = 12, atiempo = 18, fuera = 24 } local estadoDe local function pintarPulso(encendido, cuantas) local n = cuantas or (marca and #marca.flechas) or 0 for i = 1, MAX do local e = 'vacia' if marca and i <= n then e = estadoDe(i) == 'atiempo' and 'atiempo' or 'fuera' elseif marca and i == n + 1 then e = encendido and 'late' or 'siguiente' end hud.pulsos:getChildByIndex(i):setImageClip({ x = 0, y = BARRA[e], width = 34, height = 6 }) end end estadoDe = function(i) local err = math.abs(marca.tiempos[i] - pulso * (i - 1)) if not cabe(seqDe({ unpack(marca.flechas, 1, i) })) then return 'mal' end return err <= tolerancia and 'atiempo' or 'fuera' end local function pintarTitulo() local seq = seqDe(marca.flechas) local c = cancionDe(seq) if c then hud.titulo:setText(c.nombre) hud.titulo:setColor(C.text) local fuera = 0 for i = 1, #marca.flechas do if estadoDe(i) ~= 'atiempo' then fuera = fuera + 1 end end hud.detalle:setText(string.format('%s de maná · %s', c.mana > 0 and tostring(c.mana) or 'todo el', fuera == 0 and 'a tiempo' or 'fuera de pulso, sin afinar')) hud.detalle:setColor(fuera == 0 and ORO or C.text2) elseif cabe(seq) then local n = 0 for _, k in ipairs(canciones) do if k.sabe and k.seq:sub(1, #seq) == seq then n = n + 1 end end hud.titulo:setText(n == 1 and 'Una canción empieza así' or string.format('%d canciones empiezan así', n)) hud.titulo:setColor(C.text2) hud.detalle:setText('Marca la siguiente cuando el aro se cierre.') hud.detalle:setColor(C.text2) else hud.titulo:setText('Ninguna canción') hud.titulo:setColor(C.errorText) hud.detalle:setText('Esa secuencia no está en tu libro (J).') hud.detalle:setColor(C.text2) end end local function limpiarHud() for i = 1, MAX do pintarCasilla(i, 'vacia') end hud.destello:setVisible(false) end local function mostrarHud() if ocultarEn then ocultarEn = nil end hud:setImageClip({ x = 0, y = 0, width = 32, height = 32 }) hud:setOpacity(1) hud:show() hud:raise() end local function destello() if destelloEvento then removeEvent(destelloEvento) end hud.destello:setVisible(true) local k = 0 local function paso() if not hud then return end if k > 5 then hud.destello:setVisible(false) destelloEvento = nil return end hud.destello:setImageClip({ x = 64 * k, y = 0, width = 64, height = 64 }) k = k + 1 destelloEvento = scheduleEvent(paso, 90) end paso() end -- Lanzar sola (27-oct, pedido del usuario): al completar una canción suena sin soltar Ctrl. Si una canción más larga -- que sabes empieza igual (URD / URDLU), espera un latido por si sigue la secuencia. local soltar local function hayMasLarga(seq) for _, c in ipairs(canciones) do if c.sabe and #c.seq > #seq and c.seq:sub(1, #seq) == seq then return true end end return false end -- Marcar -------------------------------------------------------------------------------------------------------------- local function flecha(dir) if esperando then return end local t = ahora() if not marca then marca = { flechas = {}, tiempos = {}, t0 = t } preparado = false limpiarHud() mostrarHud() hud:setImageClip({ x = 0, y = 32, width = 32, height = 32 }) end if #marca.flechas >= MAX then marca.exceso = true hud.titulo:setText('Demasiadas notas') hud.titulo:setColor(C.errorText) hud.detalle:setText('Ninguna canción pasa de cinco. Suelta Ctrl y empieza otra vez.') return end local ms = t - marca.t0 marca.flechas[#marca.flechas + 1] = dir marca.tiempos[#marca.tiempos + 1] = ms enviar(string.format('F|%s|%d', dir, ms)) local i = #marca.flechas pintarCasilla(i, estadoDe(i), dir) mostrarJuicio(i, ms - pulso * (i - 1)) pintarTitulo() pintarPulso(false) local seq = seqDe(marca.flechas) if cancionDe(seq) then if not hayMasLarga(seq) then soltar() else local m, n = marca, i scheduleEvent(function() if marca == m and #m.flechas == n then soltar() end end, pulso + tolerancia + 40) end end end soltar = function() if not marca then return end pintarPulso(false) local n = #marca.flechas if n < MAX then -- la barra de la siguiente se apaga: ya no hay compás que seguir hud.pulsos:getChildByIndex(n + 1):setImageClip({ x = 0, y = BARRA.vacia, width = 34, height = 6 }) end local m = marca marca = nil if #m.flechas == 0 then enviar('X') return end enviar('S|' .. table.concat(m.tiempos, ',')) esperando = true hud.detalle:setText('Tocando...') hud.detalle:setColor(C.text2) scheduleEvent(function() -- sin respuesta (el servidor no la manda si Lanzamiento ya avisó): se cierra solo if esperando then esperando = false ocultarEn = ahora() + 600 end end, 1500) end local function onTecla(widget, keyCode, mods) if not conectado or not esBardo() then return false end local dir = FLECHAS[keyCode] if dir and bit.band(mods, KeyboardCtrlModifier) ~= 0 and not g_keyboard.escribiendo() then flecha(dir) -- no se devuelve true: game_walk sigue girando al personaje (todos lo ven «bailar») end return false end local function onSoltarTecla(widget, keyCode, mods) -- el motor no manda KeyUp de Ctrl (ver tic): esto solo vale si algún día lo manda; el tic mira los modificadores if conectado and (keyCode == KeyCtrlCmd or keyCode == KeyControl) then soltar() end return false end -- Servidor ------------------------------------------------------------------------------------------------------------ local function pintarLibro() end local function onOpcode(protocol, opcode, buffer) local tipo = buffer:sub(1, 1) if tipo == 'L' then local partes = partir(buffer, '|') pulso, tolerancia = tonumber(partes[2]) or 500, tonumber(partes[3]) or 110 canciones = {} for _, fila in ipairs(partir(table.concat({ unpack(partes, 4) }, '|'), '~')) do local f = partir(fila, '^') if f[1] and f[1] ~= '' then canciones[#canciones + 1] = { id = tonumber(f[1]), nombre = f[2], seq = f[3], nivel = tonumber(f[4]) or 1, mana = tonumber(f[5]) or 0, espera = tonumber(f[6]) or 0, rama = f[7] or '', sabe = f[8] == '1', texto = f[9] or '' } end end if libro and libro:isVisible() then pintarLibro() end elseif tipo == 'R' then local f = partir(buffer, '|') esperando = false if not hud then return end mostrarHud() if f[2] == '1' then hud.titulo:setText(f[3] or '') if f[4] == '1' then hud.titulo:setColor(ORO) hud.detalle:setText('¡Afinada! Un 15 % más de efecto.') hud.detalle:setColor(ORO) for i = 1, MAX do local w = casilla(i) if w.flecha:isVisible() then w:setImageClip({ x = 0, y = CLIP.atiempo, width = 34, height = 34 }) end end hud:setImageClip({ x = 0, y = 32, width = 32, height = 32 }) -- la placa entera en oro destello() else hud.titulo:setColor(C.text) hud.detalle:setText('Sonó sin afinar.') hud.detalle:setColor(C.text2) end else hud.titulo:setText('No suena') hud.titulo:setColor(C.errorText) hud.detalle:setText(f[3] or '') hud.detalle:setColor(C.errorText) end ocultarEn = ahora() + 1600 end end -- Libro de canciones -------------------------------------------------------------------------------------------------- local function secuencia(panel, seq, chica) panel:destroyChildren() for d in seq:gmatch('.') do if chica then local w = g_ui.createWidget('UIWidget', panel) w:setSize({ width = 14, height = 14 }) w:setImageSource('/modules/game_bardo/images/flecha-' .. d .. '-chica') w:setPhantom(true) else local w = g_ui.createWidget('BardoCasilla', panel) w:setImageClip({ x = 0, y = CLIP.llena, width = 34, height = 34 }) w.flecha:setImageSource('/modules/game_bardo/images/flecha-' .. d) end end end local function ficha(c) local f = libro.ficha if not c then f:hide() return end f:show() f.nombre:setText(c.nombre) f.nombre:setColor(c.sabe and C.text or C.text2) local rama = RAMAS[c.rama] f.rama:setText(rama and ('Rama ' .. rama) or (c.sabe and 'Del Bardo' or 'Aún no la sabes')) secuencia(f.seq, c.seq, false) f.tempo:destroyChildren() for _ = 1, #c.seq do local b = g_ui.createWidget('BardoMarca', f.tempo) b:setImageClip({ x = 0, y = 18, width = 34, height = 6 }) end local info = SpellInfo and SpellInfo.Default and SpellInfo.Default[c.nombre] if info then f.marco.icono:setImageSource('/images/game/spells/otmmo-32x32') f.marco.icono:setImageClip({ x = info.clientId * 32, y = 0, width = 32, height = 32 }) end f.marco.icono:setVisible(info ~= nil) f.marco:setOpacity(c.sabe and 1 or 0.5) f.mana:setText(c.mana > 0 and tostring(c.mana) or 'todo') f.espera:setText(c.espera >= 60 and string.format('%d min', math.floor(c.espera / 60)) or string.format('%d s', c.espera)) f.nivel:setText(tostring(c.nivel)) f.texto:setText(c.texto) end pintarLibro = function() if not libro then return end local lista = libro.lista lista:destroyChildren() local grupos = { { 'Las que sabes', function(c) return c.sabe end }, { 'Por aprender', function(c) return not c.sabe end }, } local primera for _, g in ipairs(grupos) do local hay = false for _, c in ipairs(canciones) do if g[2](c) then if not hay then local t = g_ui.createWidget('BardoGrupo', lista) t:setText(g[1]) hay = true end local fila = g_ui.createWidget('BardoFila', lista) fila.nombre:setText(c.nombre) fila.nombre:setColor(c.sabe and C.text or C.text2) secuencia(fila.seq, c.seq, true) fila.seq:setOpacity(c.sabe and 1 or 0.5) local rama = RAMAS_CORTAS[c.rama] fila.nivel:setText(c.sabe and (rama or '') or string.format('nivel %d%s', c.nivel, rama and (' · ' .. rama) or '')) fila:setChecked(elegida == c.id) fila.onClick = function() elegida = c.id for _, h in ipairs(lista:getChildren()) do if h.setChecked and h.nombre then h:setChecked(false) end end fila:setChecked(true) ficha(c) end primera = primera or c end end end local actual for _, c in ipairs(canciones) do if c.id == elegida then actual = c end end if not actual then actual = primera elegida = primera and primera.id end ficha(actual) end function toggleLibro() if not libro or not esBardo() then return end if libro:isVisible() then libro:hide() else enviar('P') pintarLibro() libro:show() libro:raise() libro:focus() end end function cerrarLibro() if libro then libro:hide() end end -- Ctrl pulsado sin flechas todavía: la tira aparece YA, bien a la vista, esperando la primera nota (27-oct). local preparado = false local function ctrlPulsado() return bit.band(g_window.getKeyboardModifiers(), KeyboardCtrlModifier) ~= 0 end local function preparar() if preparado or marca or esperando or not hud then return end preparado = true limpiarHud() pintarPulso(false, 0) hud.pulsos:getChildByIndex(1):setImageClip({ x = 0, y = BARRA.siguiente, width = 34, height = 6 }) hud.titulo:setText('Toca una canción') hud.titulo:setColor(ORO) hud.detalle:setText('Con Ctrl pulsado, una flecha cada vez que el aro se cierra. J: libro.') hud.detalle:setColor(C.text2) mostrarHud() hud:setImageClip({ x = 0, y = 32, width = 32, height = 32 }) -- placa en oro: se ve de reojo end -- Aro del ritmo (27-oct): se cierra sobre la casilla siguiente y llega a su tamaño justo en el latido; en oro mientras -- la nota cae dentro de la tolerancia. Si se pasa el latido, desaparece. Arte: arte/bardo/fuente/aro.py. local ARO_N = 12 local function ponerAro(i, k, oro) local p = casilla(i):getPosition() hud.aro:setPosition({ x = p.x + 17 - 36, y = p.y + 17 - 36 }) hud.aro:setImageClip({ x = k * 72, y = oro and 72 or 0, width = 72, height = 72 }) hud.aro:setVisible(true) end local function pintarAro() if marca and not marca.exceso and #marca.flechas < MAX then local n = #marca.flechas local resta = pulso * n - (ahora() - marca.t0) if resta < -tolerancia then return hud.aro:setVisible(false) end local p = math.max(0, math.min(1, 1 - resta / pulso)) ponerAro(n + 1, math.floor(p * (ARO_N - 1) + 0.5), math.abs(resta) <= tolerancia) elseif preparado then ponerAro(1, ARO_N - 1, true) -- la primera nota marca el compás: cuando quieras else hud.aro:setVisible(false) end if juicioHasta then local r = (juicioHasta - ahora()) / 600 if r <= 0 then juicioHasta = nil hud.juicio:setVisible(false) else hud.juicio:setOpacity(math.min(1, r * 2)) hud.juicio:setY(juicioY - math.floor((1 - r) * 10)) end end end -- Ciclo: pulso, fin de la muestra del resultado y Ctrl soltado ------------------------------------------------------- local function tic() if not hud then return end if hud:isVisible() then pintarAro() end if not marca and not esperando and conectado and esBardo() and not g_keyboard.escribiendo() then -- 27-oct (pedido del usuario): Ctrl solo ya NO abre la tira; sale con la primera flecha (flecha()). if preparado and not ctrlPulsado() then preparado = false ocultarEn = ahora() end end if marca then local fase = (ahora() - marca.t0) % pulso pintarPulso(fase < 120 or fase > pulso - 80) -- ¿sigue Ctrl pulsado? OJO: el motor NO marca Ctrl en isKeyPressed ni manda su KeyUp (platformwindow.cpp: Ctrl solo -- cambia los modificadores y retorna). Antes se miraba isKeyPressed(KeyCtrlCmd), que siempre daba false: a los -- 150 ms de la primera flecha se «soltaba» solo y las demás flechas se perdían (27-oct, cliente de Windows). if bit.band(g_window.getKeyboardModifiers(), KeyboardCtrlModifier) == 0 then soltar() end elseif ocultarEn and ahora() >= ocultarEn then local resto = 1 - math.min(1, (ahora() - ocultarEn) / 300) hud:setOpacity(resto) if resto <= 0 then ocultarEn = nil hud:hide() end end end -- Para las capturas sin servidor (fixture): modules.game_bardo.probar(137, 'L|...') y simular('URD', {0, 500, 1000}) function probar(op, texto) if op == OPCODE then onOpcode(nil, op, texto) end end function simular(seq, tiempos, soltarAlFinal) marca = nil esperando = false local t0 = ahora() for i = 1, #seq do local d = seq:sub(i, i) if not marca then marca = { flechas = {}, tiempos = {}, t0 = t0 } limpiarHud() mostrarHud() end marca.flechas[i] = d marca.tiempos[i] = tiempos[i] pintarCasilla(i, estadoDe(i), d) end pintarTitulo() pintarPulso(true) if soltarAlFinal then pintarPulso(false) if #seq < MAX then hud.pulsos:getChildByIndex(#seq + 1):setImageClip({ x = 0, y = BARRA.vacia, width = 34, height = 6 }) end local m = marca marca = nil esperando = true hud.detalle:setText('Tocando...') return m end end -- Barra de acción (game_actionbar): el botón de una canción no la lanza; abre su página del libro. function abrirLibroEn(id) if not libro then return end enviar('P') elegida = id pintarLibro() libro:show() libro:raise() libro:focus() end -- Secuencia en flechas pequeñas encima del botón de la barra (spellData = nil la quita). function marcarBoton(boton, spellData) if boton.bardoSeq then boton.bardoSeq:destroy() boton.bardoSeq = nil end if not spellData or not spellData.seq then return end local w = g_ui.createWidget('UIWidget', boton) w:setPhantom(true) w:addAnchor(AnchorBottom, 'parent', AnchorBottom) w:addAnchor(AnchorHorizontalCenter, 'parent', AnchorHorizontalCenter) w:setMarginBottom(1) w:setSize({ width = 7 * #spellData.seq + 2, height = 9 }) for i = 1, #spellData.seq do local f = g_ui.createWidget('UIWidget', w) f:setPhantom(true) f:setSize({ width = 9, height = 9 }) f:addAnchor(AnchorTop, 'parent', AnchorTop) f:addAnchor(AnchorLeft, 'parent', AnchorLeft) f:setMarginLeft((i - 1) * 7) f:setImageSource('/modules/game_bardo/images/flecha-' .. spellData.seq:sub(i, i) .. '-mini') end boton.bardoSeq = w local nombres = { U = 'arriba', R = 'derecha', D = 'abajo', L = 'izquierda' } local t = {} for i = 1, #spellData.seq do t[i] = nombres[spellData.seq:sub(i, i)] end boton:setTooltip(string.format('%s: Ctrl + %s. Clic: libro de canciones (J).', spellData.name, table.concat(t, ', '))) end function mostrarLibro(id) elegida = id pintarLibro() libro:show() libro:raise() end local function conectar() local raiz = modules.game_interface.getRootPanel() if conectado or not raiz then return end connect(raiz, { onKeyDown = onTecla, onKeyUp = onSoltarTecla }) conectado = true end local function desconectar() local raiz = modules.game_interface.getRootPanel() if conectado and raiz then disconnect(raiz, { onKeyDown = onTecla, onKeyUp = onSoltarTecla }) end conectado = false end function bardoController:onInit() local raiz = modules.game_interface.getRootPanel() g_ui.importStyle('bardo') hud = g_ui.createWidget('BardoHud', raiz) hud:addAnchor(AnchorBottom, 'parent', AnchorBottom) hud:addAnchor(AnchorHorizontalCenter, 'parent', AnchorHorizontalCenter) hud:setMarginBottom(240) -- por encima de las barras de vida y maná y de las barras de acción for i = 1, MAX do g_ui.createWidget('BardoCasilla', hud.casillas) g_ui.createWidget('BardoMarca', hud.pulsos) end hud:hide() libro = g_ui.displayUI('bardo') if OtmmoVentanas then OtmmoVentanas.recordar(libro, 'libro-canciones') end libro:hide() ProtocolGame.registerExtendedOpcode(OPCODE, onOpcode) Keybind.new('Ventanas', 'Libro de canciones', { [CHAT_MODE.ON] = '', [CHAT_MODE.OFF] = 'J' }, '') Keybind.bind('Ventanas', 'Libro de canciones', { { type = KEY_DOWN, callback = function() if not g_keyboard.escribiendo() then toggleLibro() end end } }) if g_game.isOnline() then self:onGameStart() end end function bardoController:onGameStart() conectar() if ciclo then removeEvent(ciclo) end ciclo = cycleEvent(tic, 30) if esBardo() then enviar('P') -- aviso la primera vez que entra un bardo en este cliente: la tira en oro unos segundos if not g_settings.getBoolean('bardo-aviso-visto') then g_settings.set('bardo-aviso-visto', true) scheduleEvent(function() if not hud or marca then return end limpiarHud() pintarPulso(false, 0) hud.titulo:setText('Eres Bardo: tus hechizos son canciones') hud.titulo:setColor(ORO) hud.detalle:setText('Mantén Ctrl y marca las flechas; J abre tu libro de canciones.') hud.detalle:setColor(C.text) mostrarHud() hud:setImageClip({ x = 0, y = 32, width = 32, height = 32 }) ocultarEn = ahora() + 9000 end, 2500) end end end function bardoController:onGameEnd() if ciclo then removeEvent(ciclo) ciclo = nil end marca, esperando, ocultarEn, canciones = nil, false, nil, {} if hud then hud:hide() end if libro then libro:hide() end end function bardoController:onTerminate() self:onGameEnd() desconectar() ProtocolGame.unregisterExtendedOpcode(OPCODE) Keybind.delete('Ventanas', 'Libro de canciones') if hud then hud:destroy() end if libro then libro:destroy() end hud, libro = nil, nil end