---
title: "Ruby - オールイン！ — ブロスタ ハイパーチャージ | BrawlHub GG"
description: "Ruby - オールイン！ — ブロスタのハイパーチャージ。WebP・PNG・JPEGなどで無料ダウンロード（高画質）。"
url: https://brawlhub.gg/ja/resources/hypercharges/ruby/
lang: ja
---

[戻る](https://brawlhub.gg/ja/resources/hypercharges/)
Ruby - オールイン！

ハイパーチャージ

# Ruby - オールイン！

WebP 14.2 KB 256 × 256 px

形式

WebP

解像度

256 × 256 px

サイズ

14.2 KB

解像度 · シンボル

256 × 256 px

サイズ · シンボル

10.7 KB

タイプ

ハイパーチャージ

Brawler

[Ruby](https://brawlhub.gg/ja/brawlers/ruby/)

' + '
'); var fmodal = el(' '); document.body.appendChild(modal); document.body.appendChild(fmodal); var modalTitle = modal.querySelector('[data-title]'), modalOpts = modal.querySelector('[data-opts]'); var fcanvas = fmodal.querySelector('[data-fcanvas]'), frange = fmodal.querySelector('[data-frange]'), flabel = fmodal.querySelector('[data-flabel]'), fdl = fmodal.querySelector('[data-fdl]'); var modalReject = null, fReject = null; function hideModal() { modal.hidden = true; modalOpts.innerHTML = ''; } function closeModal() { var r = modalReject; modalReject = null; hideModal(); if (r) r(); } function fClose() { fmodal.hidden = true; var r = fReject; fReject = null; if (r) r(); } Array.prototype.forEach.call(modal.querySelectorAll('[data-x]'), function (e) { e.addEventListener('click', closeModal); }); Array.prototype.forEach.call(fmodal.querySelectorAll('[data-fx]'), function (e) { e.addEventListener('click', fClose); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { if (!modal.hidden) closeModal(); else if (!fmodal.hidden) fClose(); } }); // ===== utilidades ===== // Extension del fichero: 'jpeg' se guarda .jpg y 'webp-still' (un fotograma) es un .webp normal. function extOf(fmt) { return fmt === 'jpeg' ? 'jpg' : (fmt === 'webp-still' ? 'webp' : fmt); } // Formatos que son UNA imagen fija sacada de un animado (llevan selector de Fotograma, no fps). function esFijo(fmt) { return fmt === 'png' || fmt === 'jpeg' || fmt === 'webp-still'; } function saveBlob(blob, name) { var u = URL.createObjectURL(blob), a = document.createElement('a'); a.href = u; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(u); }, 4000); } function toFmt(blob, fmt) { // 'webp-still' = el WebP de UN fotograma. Cuando la fuente ya ES una imagen fija (un mapa // estático, p.ej.) no hay nada que convertir: todos nuestros originales son webp. Sin esta // rama caía al canvas de abajo y salía un JPEG con extensión .webp. if (fmt === 'webp' || fmt === 'webp-still') return Promise.resolve(blob); return new Promise(function (res) { var img = new Image(); img.onload = function () { var cv = document.createElement('canvas'); cv.width = img.naturalWidth; cv.height = img.naturalHeight; var ctx = cv.getContext('2d'); // Los formatos SIN alfa se aplanan sobre NEGRO (antes: blanco). Es lo que ya hacia el mp4, // y sobre blanco los assets con contorno oscuro se veian sucios por el borde. if (fmt === 'jpeg') { ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, cv.width, cv.height); } ctx.drawImage(img, 0, 0); cv.toBlob(function (b) { res(b); }, fmt === 'png' ? 'image/png' : 'image/jpeg', 0.92); }; img.onerror = function () { res(null); }; img.src = URL.createObjectURL(blob); }); } // WebP animado → en BUCLE y REPOSANDO en su pose. Dos cosas: // · nº de vueltas del chunk ANIM a 0 (las reacciones se guardan con una sola vuelta). // · el ÚLTIMO fotograma se alarga: es el único con el contorno negro grueso de la reacción, y // repitiendo sin pausa se veía 1 de cada 29 fotogramas — o sea, nunca. // Los bloques se recorren por la cabecera del RIFF (4 letras + 4 de tamaño) y no buscando las // letras a pelo: dentro de los datos comprimidos aparecen por azar, y además hace falta el // ÚLTIMO bloque ANMF, que está al final del fichero y no en la cabecera. var REPOSO_MS = 1200; function enBucle(blob) { return blob.arrayBuffer().then(function (buf) { var b = new Uint8Array(buf); var p = 12, ult = -1; while (p + 8 <= b.length) { var cc = String.fromCharCode(b[p], b[p + 1], b[p + 2], b[p + 3]); var sz = b[p + 4] | (b[p + 5] << 8) | (b[p + 6] << 16) | (b[p + 7] * 16777216); if (sz < 0 || p + 8 + sz > b.length) { break; } if (cc === 'ANIM') { b[p + 12] = 0; b[p + 13] = 0; } else if (cc === 'ANMF') { ult = p; } p += 8 + sz + (sz & 1); } if (ult > 0) { // duración del fotograma: 3 bytes desde el 20, en ms var d = Math.max(REPOSO_MS, b[ult + 20] | (b[ult + 21] << 8) | (b[ult + 22] << 16)); b[ult + 20] = d & 255; b[ult + 21] = (d >> 8) & 255; b[ult + 22] = (d >> 16) & 255; } return new Blob([b], { type: 'image/webp' }); }); } function animUrl(slug, fmt, extra) { return CFG.animExport + '?slug=' + encodeURIComponent(slug) + '&fmt=' + fmt + '&t=' + CFG.animT + '&n=' + CFG.nonce + (extra || '') + postQS; } // t (dir del endpoint AVIF estático) por defecto = CFG.dir; se puede sobreescribir por item (it.avifT) // para las VARIANTES de habilidad (el símbolo vive en gadget-symbols/, etc.). function imgAvifUrl(slug, t) { return CFG.imgAvif + '?t=' + encodeURIComponent(t || CFG.dir) + '&slug=' + encodeURIComponent(slug) + '&n=' + CFG.nonce; } function animFetch(url) { return fetch(url, { headers: { 'X-WP-Nonce': CFG.restNonce } }); } // (7-nov-2026) 503 = «ocupado» (el servidor pinta 8 a la vez): antes 6 reintentos de ~0,75 s (~5 s) y pintar un mapa la 1.ª vez tarda // más → en un ZIP de 428 mapas se quedaron fuera 60 sin avisar. Ahora espera creciente (1, 2, 4, 8 s… ±25 %) hasta 60 s en total. function animFetchRetry(url, tries, k, t0) { k = k || 0; t0 = t0 || Date.now(); return animFetch(url).then(function (r) { var ms = Math.min(1000 * Math.pow(2, k), 8000) * (0.75 + Math.random() * 0.5); if (r.status === 503 && Date.now() - t0 + ms < 60000) { return new Promise(function (res) { setTimeout(res, ms); }).then(function () { return animFetchRetry(url, tries, k + 1, t0); }); } return r; }); } var jszipP = null; function loadJSZip() { if (jszipP) return jszipP; jszipP = window.JSZip ? Promise.resolve(window.JSZip) : new Promise(function (res, rej) { var sc = document.createElement('script'); sc.src = CFG.jszip; sc.onload = function () { res(window.JSZip); }; sc.onerror = rej; document.head.appendChild(sc); }); return jszipP; } function fmtBytes(n) { n = +n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(1) + ' MB'; } // `dl` (el nombre bonito) manda... SALVO que se haya pedido expresamente el identificador. Antes // `dl` ganaba siempre y por eso la sección "Nombre de fichero" era un control INERTE en todo lo que // lo trae: mapas, retratos y habilidades. En iconos funcionaba de casualidad, porque no llevan `dl`. function nameOf(it, mode) { if (mode === 'num' && it.num) { return String(it.num); } // 'loc' = el nombre VISIBLE (el localizado que sale en la rejilla). Se quitan solo los // caracteres que un sistema de ficheros no admite; los espacios y los acentos se quedan, // porque la gracia es que el fichero se llame como el mapa. if (mode === 'loc' && it.label) { return String(it.label).replace(/[\\/:*?"<>|]+/g, '').trim(); } return it.dl || it.slug; } // ===== modales ===== function pick(title, options) { return new Promise(function (resolve, reject) { modalReject = reject; modalTitle.textContent = title; modalOpts.innerHTML = ''; options.forEach(function (o) { var b = document.createElement('button'); b.type = 'button'; b.className = 'bh-res-modal__opt'; if (o.sub) { var s = document.createElement('span'); s.className = 'bh-res-modal__opt-sub'; s.textContent = o.sub; var m = document.createElement('span'); m.className = 'bh-res-modal__opt-main'; m.textContent = o.label; b.appendChild(s); b.appendChild(m); } else { b.textContent = o.label; } if (o.note) { var nt = document.createElement('span'); nt.className = 'bh-res-modal__opt-note'; nt.textContent = o.note; b.appendChild(nt); } b.addEventListener('click', function () { modalReject = null; hideModal(); resolve(o.v); }); modalOpts.appendChild(b); }); modal.hidden = false; if (modalOpts.firstChild) modalOpts.firstChild.focus(); }); } // Dropdown CUSTOM (mismo estilo que los filtros de la web). options = [{v,label} | {header:'…'}]. function makeDD(options, value, disabled, onChange) { var dd = document.createElement('div'); dd.className = 'bh-res-dd bh-res-dd--modal'; var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'bh-res-dd__btn'; btn.setAttribute('aria-haspopup', 'listbox'); var cur = document.createElement('span'); cur.className = 'bh-res-dd__cur'; var curl = document.createElement('span'); curl.className = 'bh-res-dd__lbl'; cur.appendChild(curl); btn.appendChild(cur); btn.insertAdjacentHTML('beforeend', ' '); var panel = document.createElement('ul'); panel.className = 'bh-res-dd__panel'; panel.hidden = true; panel.setAttribute('role', 'listbox'); var curVal = null, optEls = []; function build() { panel.innerHTML = ''; optEls = []; options.forEach(function (o) { if (o.header != null) { var h = document.createElement('li'); h.className = 'bh-res-dd__head'; h.textContent = o.header; panel.appendChild(h); return; } var li = document.createElement('li'); li.className = 'bh-res-dd__opt'; li.setAttribute('role', 'option'); li.dataset.v = o.v; var l = document.createElement('span'); l.className = 'bh-res-dd__lbl'; l.textContent = o.label; li.appendChild(l); li.addEventListener('click', function () { setVal(o.v); open(false); if (onChange) onChange(curVal); }); panel.appendChild(li); optEls.push(li); }); } build(); function firstVal() { for (var i = 0; i < options.length; i++) { if (options[i].header == null) return options[i].v; } return null; } function optLabel(v) { for (var i = 0; i < options.length; i++) if (options[i].header == null && options[i].v === v) return options[i].label; return v; } function setVal(v) { curVal = v; curl.textContent = optLabel(v); optEls.forEach(function (li) { li.classList.toggle('is-active', li.dataset.v === v); }); } // El panel se coloca A MANO con position:fixed en vez de dejarlo absoluto bajo el botón. Con // varias secciones (los mapas traen modo + tema + variante + formato + fotograma) el modal es // alto y el desplegable de la última fila se salía POR DEBAJO de la pantalla — lo vio el // usuario. Fijo: no lo recorta el modal, se abre hacia ARRIBA si abajo no cabe, y el alto se // limita a lo que quede libre. function coloca() { var r = btn.getBoundingClientRect(), m = 8; var abajo = window.innerHeight - r.bottom - m, arriba = r.top - m; var subir = abajo < 200 && arriba > abajo; panel.style.position = 'fixed'; panel.style.left = Math.max(4, r.left) + 'px'; panel.style.width = r.width + 'px'; panel.style.minWidth = '0'; panel.style.maxHeight = Math.max(120, Math.min(340, subir ? arriba : abajo)) + 'px'; if (subir) { panel.style.top = 'auto'; panel.style.bottom = (window.innerHeight - r.top + 5) + 'px'; } else { panel.style.bottom = 'auto'; panel.style.top = (r.bottom + 5) + 'px'; } } var recolocar = function () { if (!panel.hidden) { coloca(); } }; function open(o) { panel.hidden = !o; btn.setAttribute('aria-expanded', o ? 'true' : 'false'); if (o) { coloca(); window.addEventListener('resize', recolocar); window.addEventListener('scroll', recolocar, true); // captura: también si scrollea el modal } else { window.removeEventListener('resize', recolocar); window.removeEventListener('scroll', recolocar, true); } } btn.addEventListener('click', function (e) { e.stopPropagation(); if (btn.disabled) return; open(panel.hidden); }); ddCloseFns.push(function () { open(false); }); setVal(value != null ? value : firstVal()); if (disabled || optEls.length <= 1) btn.disabled = true; dd.appendChild(btn); dd.appendChild(panel); return { el: dd, getValue: function () { return curVal; }, setValue: setVal, setOptLabel: function (v, text) { for (var i = 0; i < optEls.length; i++) if (optEls[i].dataset.v === v) optEls[i].querySelector('.bh-res-dd__lbl').textContent = text; if (curVal === v) curl.textContent = text; } }; } function exportOptions(sections, onChange) { return new Promise(function (resolve, reject) { modalReject = reject; modalTitle.textContent = T.exportOpts; modalOpts.innerHTML = ''; var sels = {}; sections.forEach(function (sec) { var row = document.createElement('div'); row.className = 'bh-res-optrow'; var lb = document.createElement('span'); lb.className = 'bh-res-optrow__lbl'; lb.textContent = sec.label || ''; if (sec.label) row.appendChild(lb); if (sec.shot) { // 📸 (26-oct-2026) Vista previa de una CAPTURA (la 3D de Recursos): `shot()` devuelve la imagen (dataURL) tal como saldrá; // `refresh` la repinta cuando cambia otra opción (la proporción). var sp = document.createElement('img'); sp.className = 'bh-res-shot'; sp.alt = ''; row.classList.add('bh-res-optrow--shot'); row.appendChild(sp); sels[sec.key] = { row: row, getValue: function () { return ''; }, refresh: (function (f, im) { return function () { try { var r = f(); if (r && r.then) { r.then(function (u) { im.src = u || ''; }); } else { im.src = r || ''; } } catch (e) {} // (puede ser una promesa) }; })(sec.shot, sp) }; } else if (sec.toggle) { // 🔘 Interruptor (el mismo de la cuenta: .bh-acc__seg-sw); valor '1' / ''. // (el usuario: «el toggle ponlo bien a la derecha»): el texto es la etiqueta de la fila, a la izquierda, y el interruptor a la derecha // (y «que cubra también el texto»: la pastilla entera, texto a la izquierda e interruptor a la derecha, a todo lo ancho) var tg = document.createElement('button'); tg.type = 'button'; tg.className = 'bh-acc__seg-sw bh-res-tgl'; tg.setAttribute('aria-pressed', 'false'); tg.innerHTML = ' '; tg.firstChild.textContent = sec.toggle; row.classList.add('bh-res-optrow--toggle'); row.appendChild(tg); sels[sec.key] = { row: row, getValue: (function (b) { return function () { return b.classList.contains('is-on') ? '1' : ''; }; })(tg) }; tg.addEventListener('click', (function (b, k) { return function () { var on = !b.classList.contains('is-on'); b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); if (onChange) onChange(k, on ? '1' : '', sels); }; })(tg, sec.key)); } else if (sec.frame) { var fr = sec.frame, wrap = document.createElement('div'); wrap.className = 'bh-res-frame'; var prev = document.createElement('img'); prev.className = 'bh-res-frame__prev'; prev.alt = ''; var rng = document.createElement('input'); rng.type = 'range'; rng.className = 'bh-res-frame__range'; rng.min = 0; rng.max = Math.max(0, fr.count - 1); rng.step = 1; rng.value = fr.value; var cnt = document.createElement('span'); cnt.className = 'bh-res-frame__n'; var fcache = {}, fdbnc; var fshow = function (i) { cnt.textContent = (i + 1) + ' / ' + fr.count; if (fcache[i]) { prev.src = fcache[i]; return; } clearTimeout(fdbnc); fdbnc = setTimeout(function () { animFetch(fr.urlFor(i)).then(function (r) { return r.blob(); }).then(function (b) { fcache[i] = URL.createObjectURL(b); if (+rng.value === i) prev.src = fcache[i]; }).catch(function () {}); }, 130); }; rng.addEventListener('input', function () { fshow(+rng.value); }); fshow(+rng.value); wrap.appendChild(prev); wrap.appendChild(rng); wrap.appendChild(cnt); row.appendChild(wrap); // `reset` = la fuente ha cambiado (otro tema/modo del mismo mapa): la caché de // fotogramas ya no vale y hay que repintar el que esté elegido. sels[sec.key] = { row: row, getValue: (function (r) { return function () { return String(r.value); }; })(rng), reset: function () { fcache = {}; fshow(+rng.value); } }; } else { var dd = makeDD(sec.options, sec.value, sec.disabled, function (v) { if (onChange) onChange(sec.key, v, sels); }); row.appendChild(dd.el); sels[sec.key] = { row: row, getValue: dd.getValue, setValue: dd.setValue, setOptLabel: dd.setOptLabel }; } modalOpts.appendChild(row); }); if (onChange) { Object.keys(sels).forEach(function (k) { onChange(k, sels[k].getValue(), sels); }); } var go = document.createElement('button'); go.type = 'button'; go.className = 'bh-res-modal__opt bh-res-modal__go'; go.textContent = T.dl; go.addEventListener('click', function () { modalReject = null; var out = {}; Object.keys(sels).forEach(function (k) { out[k] = sels[k].getValue(); }); hideModal(); resolve(out); }); modalOpts.appendChild(go); modal.hidden = false; go.focus(); }); } // ===== selector de frame (animado -> imagen estática, extracción server-side) ===== function frameUrl(slug, idx, fmt) { return animUrl(slug, fmt || 'png', '&frame=' + idx); } function representativeBlob(slug, count, fmt, def) { var n = (def != null) ? def : Math.max(0, Math.floor(count * 0.6)); return animFetchRetry(frameUrl(slug, n, fmt), 6).then(function (r) { if (!r.ok) throw 0; return r.blob(); }); } function pickFrame(slug, count, fmt, def) { return new Promise(function (resolve, reject) { fReject = reject; var cur = (def != null) ? Math.max(0, Math.min(count - 1, def)) : Math.max(0, Math.floor(count * 0.6)); var cache = {}, ctx = fcanvas.getContext('2d'), dbnc; function paint(url) { var img = new Image(); img.onload = function () { fcanvas.width = img.naturalWidth; fcanvas.height = img.naturalHeight; ctx.clearRect(0, 0, fcanvas.width, fcanvas.height); ctx.drawImage(img, 0, 0); }; img.src = url; } function preview(i) { if (cache[i]) { if ((parseInt(frange.value, 10) || 0) === i) paint(cache[i]); return; } fmodal.classList.add('is-loading'); animFetch(frameUrl(slug, i, 'png')).then(function (r) { return r.blob(); }).then(function (b) { cache[i] = URL.createObjectURL(b); if ((parseInt(frange.value, 10) || 0) === i) { paint(cache[i]); fmodal.classList.remove('is-loading'); } }).catch(function () { fmodal.classList.remove('is-loading'); }); } frange.max = count - 1; frange.value = cur; frange.oninput = function () { cur = parseInt(frange.value, 10) || 0; flabel.textContent = T.frame + ' ' + (cur + 1) + ' / ' + count; if (cache[cur]) { paint(cache[cur]); return; } clearTimeout(dbnc); dbnc = setTimeout(function () { preview(cur); }, 140); }; flabel.textContent = T.frame + ' ' + (cur + 1) + ' / ' + count; preview(cur); fdl.onclick = function () { fReject = null; fmodal.hidden = true; Object.keys(cache).forEach(function (k) { URL.revokeObjectURL(cache[k]); }); animFetch(frameUrl(slug, cur, fmt)).then(function (r) { if (!r.ok) throw 0; return r.blob(); }).then(resolve).catch(reject); }; fmodal.hidden = false; }); } // 🪝 Gancho OPCIONAL de post-proceso: si una página registra `BHX.setPost(fn)`, cada blob pasa // por ahí antes de guardarse (lo usa Recursos para meter la reacción dentro del bocadillo). Sin // registrar nada se comporta EXACTAMENTE igual que antes, así que no afecta al resto de páginas. var postFn = null; // Algunas transformaciones NO se pueden hacer en el navegador (un webp ANIMADO no se recompone en // un canvas), así que la página puede pedir además que las llamadas al endpoint lleven un extra // —`&boc=1`— y sea el servidor quien lo haga. `BHX.setPost(fn, extra)`. var postQS = ''; // ⚠️ `saveOne` NO pasa por `convItem`: tiene su propia cadena, con CINCO ramas. Si el gancho solo // se engancha en `convItem`, el ZIP sale post-procesado y la descarga de UNO no — que es justo lo // que pasó. Todas las ramas guardan por aquí. function guarda(b, nombre, fmt, it) { if (!b) { return; } if (!postFn) { saveBlob(b, nombre); return; } Promise.resolve(postFn(b, fmt, it)).then(function (r) { saveBlob(r || b, nombre); }).catch(function () { saveBlob(b, nombre); }); } // ===== descarga de UN item / de un ZIP ===== function saveOne(it, mode, fmt, fps, frame) { var name = nameOf(it, mode), ext = extOf(fmt); // it.animV = VERSIÓN de la fuente en el servidor (banners: 'sm' = la reducida). Sin ella, el // endpoint parte del máster. No aplica a la extracción de frame: el índice del slider es // SIEMPRE del máster (una imagen fija no tiene versión). var fpsx = (fps ? '&fps=' + fps : '') + (it.animV ? '&v=' + it.animV : ''); if (it.vid && fmt === 'mp4') { // vídeo de verdad (fondos de lobby): el mp4 tal cual document.body.style.cursor = 'progress'; fetch(it.vid).then(function (r) { if (!r.ok) throw 0; return r.blob(); }) .then(function (b) { guarda(b, name + '.mp4', fmt, it); }).catch(function () {}).finally(function () { document.body.style.cursor = ''; }); return; } if (fmt === 'avif' && !CFG.isIcons && !it.anim) { // AVIF estático -> endpoint GD document.body.style.cursor = 'progress'; animFetch(imgAvifUrl(it.slug, it.avifT)).then(function (r) { if (!r.ok) throw 0; return r.blob(); }) .then(function (b) { guarda(b, name + '.avif', fmt, it); }).catch(function () {}).finally(function () { document.body.style.cursor = ''; }); return; } if (fmt === 'gif' || fmt === 'mov' || fmt === 'avif' || fmt === 'mp4') { // animado -> conversión server-side document.body.style.cursor = 'progress'; animFetch(animUrl(it.slug, fmt, fpsx)).then(function (r) { if (!r.ok) throw 0; return r.blob(); }) .then(function (b) { guarda(b, name + '.' + fmt, fmt, it); }).catch(function () {}).finally(function () { document.body.style.cursor = ''; }); return; } // WebP ANIMADO -> endpoint. Dos casos: // - `fps`: se ha elegido una fps distinta y hay que reconvertir (banners e iconos). // - `it.animOnly`: la animación NO EXISTE como fichero (mapas: se renderiza al vuelo desde // el atlas del "pop"), así que `it.orig` es la imagen ESTÁTICA y caer al fetch de abajo // bajaba un webp de 1 fotograma llamándolo animado. Pasó de verdad: lo cazó el usuario. // `postQS` (p.ej. `&boc=1`) = hay que componer en el SERVIDOR, así que aunque no se haya pedido // otra fps el webp animado va por el endpoint y no por el fichero de disco. if (fmt === 'webp' && it.anim && (fps || it.animOnly || postQS)) { document.body.style.cursor = 'progress'; animFetch(animUrl(it.slug, 'webp', fpsx)).then(function (r) { if (!r.ok) throw 0; return r.blob(); }) .then(function (b) { guarda(b, name + '.webp', fmt, it); }).catch(function () {}).finally(function () { document.body.style.cursor = ''; }); return; } if (it.anim && esFijo(fmt)) { // animado -> imagen estática: frame ELEGIDO en el modal (slider) var fi = (frame != null && frame !== '') ? Math.max(0, Math.min(it.anim - 1, +frame)) : (it.defaultFrame != null ? +it.defaultFrame : Math.round(it.anim * 0.6)); document.body.style.cursor = 'progress'; animFetch(frameUrl(it.slug, fi, fmt === 'webp-still' ? 'webp' : fmt)).then(function (r) { if (!r.ok) throw 0; return r.blob(); }) .then(function (b) { guarda(b, name + '.' + ext, fmt, it); }).catch(function () {}).finally(function () { document.body.style.cursor = ''; }); return; } fetch(it.orig).then(function (r) { return r.blob(); }).then(function (b) { return (it.anim && fmt === 'webp') ? enBucle(b) : toFmt(b, fmt); }) .then(function (b) { guarda(b, name + '.' + ext, fmt, it); }).catch(function () {}); } function convItem(it, fmt, fps) { var r = convItemBase(it, fmt, fps); return postFn ? r.then(function (b) { return b ? postFn(b, fmt, it) : b; }) : r; } function convItemBase(it, fmt, fps) { var fpsx = (fps ? '&fps=' + fps : '') + (it.animV ? '&v=' + it.animV : ''); if (it.vid && fmt === 'mp4') { return fetch(it.vid).then(function (r) { if (!r.ok) throw 0; return r.blob(); }); } if (fmt === 'mp4' && !it.anim && !it.vid) { return Promise.resolve(null); } // en un ZIP de vídeos, las imágenes fijas no tienen mp4 if (fmt === 'avif' && !CFG.isIcons && !it.anim) { return animFetchRetry(imgAvifUrl(it.slug, it.avifT), 6).then(function (r) { if (!r.ok) throw 0; return r.blob(); }); } if (fmt === 'gif' || fmt === 'mov' || fmt === 'avif' || fmt === 'mp4') { return animFetchRetry(animUrl(it.slug, fmt, fpsx), 6).then(function (r) { if (!r.ok) throw 0; return r.blob(); }); } if (fmt === 'webp' && it.anim && (fps || it.animOnly || postQS)) { return animFetchRetry(animUrl(it.slug, 'webp', fpsx), 6).then(function (r) { if (!r.ok) throw 0; return r.blob(); }); } if (it.anim && esFijo(fmt)) { return representativeBlob(it.slug, it.anim, fmt === 'webp-still' ? 'webp' : fmt, it.defaultFrame); } return fetch(it.orig).then(function (r) { return r.blob(); }).then(function (blob) { return (it.anim && fmt === 'webp') ? enBucle(blob) : toFmt(blob, fmt); }); } function savePack(items, mode, fmt, zipName, fps, packBtn, packProg) { var ext = extOf(fmt), fallos = 0; if (packBtn) packBtn.disabled = true; if (packProg) { packProg.hidden = false; packProg.textContent = '0 / ' + items.length; } loadJSZip().then(function (JSZip) { // (7-nov-2026) lo que pinta el SERVIDOR (animados, gif/mov/avif/mp4) va de 3 en 3: cada IP tiene 4 huecos (8 para todos, // bh_anim_export_lock); lo demás, de 16 en 16 var srv = ['gif', 'mov', 'avif', 'mp4'].indexOf(fmt) >= 0 || (fmt === 'webp' && (fps || postQS || items.some(function (i) { return i.anim && i.animOnly; }))); var zip = new JSZip(), done = 0, j = 0, LIMIT = srv ? 3 : 16; return new Promise(function (resolve) { function next() { if (j >= items.length) { if (done >= items.length) resolve(zip); return; } var it = items[j++], name = nameOf(it, mode); convItem(it, fmt, fps).then(function (b) { if (b) zip.file(name + '.' + ext, b); }).catch(function () { fallos++; }) .finally(function () { done++; if (packProg) packProg.textContent = done + ' / ' + items.length; next(); }); } for (var k = 0; k < Math.min(LIMIT, items.length); k++) next(); }); }).then(function (zip) { if (packProg) packProg.textContent = 'ZIP…'; return zip.generateAsync({ type: 'blob', compression: 'STORE' }); }) .then(function (zb) { saveBlob(zb, (zipName || 'export') + '-' + ext + '.zip'); }).catch(function () {}) .finally(function () { if (packBtn) packBtn.disabled = false; if (!packProg) return; // (7-nov-2026) si faltan archivos en el ZIP, se dice (antes se quedaban fuera sin aviso) y el aviso se queda 15 s if (fallos) { packProg.textContent = T.zipFaltan.replace('{n}', fallos).replace('{t}', items.length); setTimeout(function () { packProg.hidden = true; }, 15000); } else packProg.hidden = true; }); } // ===== flujo estándar: construye el modal DINÁMICAMENTE según los items y el contexto ===== // Puntos de extensión para lo que es propio de UNA página y no tiene sentido meter aquí: // opts.preSections = secciones que van ANTES de Extensión (la Versión de un banner manda sobre // qué extensiones tienen sentido, así que se elige primero). // opts.extraSections = secciones extra tras Extensión. // opts.onChange(key, val, sels) = se llama DESPUÉS del onChange interno (puede sobreescribirlo). // opts.applySel(list, sel) = última pasada sobre los items ya elegidos; devuelve la lista a bajar. // (extraSections+applySel: ZIP de hipercargas. preSections+onChange+applySel: versiones de banner.) function flow(items, opts) { opts = opts || {}; if (!items || !items.length) return; var isPack = !!opts.isPack, needScope = !!opts.needScope, zipName = opts.zipName; var hasNum = items.some(function (i) { return i.num; }); // ¿Todos tienen nombre VISIBLE (localizado)? Entonces se puede ofrecer como nombre de fichero. var hasLoc = items.every(function (i) { return !!i.label; }); var allAnim = items.every(function (i) { return i.anim; }); var sections = []; if (needScope) { sections.push({ key: 'scope', label: T.content, options: [{ v: 'all', label: T.scopeAll }, { v: 'anim', label: T.scopeAnim }, { v: 'static', label: T.scopeStatic }] }); } if (hasNum || hasLoc) { // La etiqueta enseña el nombre REAL de cada opción (`nameOf`), no el slug: en mapas el slug es // el propio map_id y las dos opciones parecían la misma. var nameOpts = [{ v: 'code', label: isPack ? T.code : (nameOf(items[0], 'code') + ' (' + T.code + ')') }]; if (hasNum) { nameOpts.push({ v: 'num', label: isPack ? T.num : (items[0].num + ' (' + T.num + ')') }); } if (hasLoc) { // El nombre traducido: el que se ve en la rejilla. El por DEFECTO sigue siendo el inglés. nameOpts.push({ v: 'loc', label: isPack ? T.loc : (nameOf(items[0], 'loc') + ' (' + T.loc + ')') }); } if (nameOpts.length > 1) { sections.push({ key: 'mode', label: T.fn, options: nameOpts }); } } // Extensión (dropdown agrupado ANIMADO/ESTÁTICO si es animado). MP4 = vídeo H.264 (sin alfa) para // banners e iconos animados. PNG/JPEG = un fotograma estático (con selector inline). var wnote = (!isPack && items[0].size) ? ' · ' + fmtBytes(items[0].size) : ''; var fmts; // Fondos de lobby en VÍDEO (it.vid): MP4 = el vídeo tal cual; las imágenes fijas, de su portada. En un ZIP con // vídeos e imágenes, «MP4» baja solo los vídeos. var hayVid = items.some(function (x) { return x.vid; }), allVid = hayVid && items.every(function (x) { return x.vid; }); if (hayVid) { fmts = [{ header: T.grpAnim }, { v: 'mp4', label: 'MP4' + (allVid ? ' · ' + T.orig + wnote : '') }, { header: T.grpStatic }, { v: 'webp', label: 'WebP' + (allVid ? '' : ' · ' + T.orig) }, { v: 'png', label: 'PNG' }, { v: 'jpeg', label: 'JPEG' }]; } else if (allAnim) { // MP4 para CUALQUIER animado: lo convierte el endpoint desde el webp, y da igual de que // tipo venga. Estaba limitado a banners e iconos y por eso faltaba en los mapas. // 'webp-still' = WebP de UN fotograma: hasta ahora el grupo estatico solo tenia PNG y JPEG, // asi que del formato propio del sitio solo se podia bajar la version animada. fmts = [{ header: T.grpAnim }, { v: 'webp', label: 'WebP · ' + T.orig + wnote }]; fmts.push({ v: 'mp4', label: 'MP4' }); // GIF: vuelve (se retiró en 1.0.352 porque el conversor dejaba transparente el negro; arreglado en // export-anim.py con un índice de paleta reservado para la transparencia). fmts.push({ v: 'gif', label: 'GIF' }); fmts.push({ v: 'mov', label: 'MOV' }); fmts.push({ v: 'avif', label: 'AVIF' }); fmts.push({ header: T.grpStatic }, { v: 'webp-still', label: 'WebP' }, { v: 'png', label: 'PNG' }, { v: 'jpeg', label: 'JPEG' }); } else { fmts = [{ v: 'webp', label: 'WebP · ' + T.orig + wnote }, { v: 'png', label: 'PNG' }, { v: 'jpeg', label: 'JPEG' }, { v: 'avif', label: 'AVIF' }]; } if (opts.preSections) { opts.preSections.forEach(function (s) { sections.push(s); }); } var fmtSec = { key: 'fmt', label: T.fmt, options: fmts }; if (allVid) { fmtSec.value = 'mp4'; } if (CFG.animT === 'banners' && allAnim) { fmtSec.value = 'mp4'; } // banner animado -> MP4 por defecto sections.push(fmtSec); if (opts.extraSections) { opts.extraSections.forEach(function (s) { sections.push(s); }); } // Fotograma inline (1 item animado -> slider + preview EN VIVO al elegir PNG/JPEG). // El slug puede cambiar DENTRO del modal (en mapas se eligen ahí el modo, el tema y el marco, y // cada combinación es OTRO fichero), así que la vista previa lo lee de un objeto VIVO y no de // una copia; la página lo recibe por `opts.frameRef` y, al cambiarlo, llama a `sels.frame.reset()`. if (!isPack && items.length === 1 && items[0].anim) { var fc = items[0].anim, fdef = Math.max(0, Math.min(fc - 1, items[0].defaultFrame != null ? +items[0].defaultFrame : Math.round(fc * 0.6))); var fref = { slug: items[0].slug }; if (opts.frameRef) { opts.frameRef(fref); } sections.push({ key: 'frame', label: T.frame, frame: { count: fc, value: fdef, urlFor: function (i) { return frameUrl(fref.slug, i, 'png'); } } }); } // FPS: SOLO en descarga de 1 item (en pack/ZIP se bajan como el ORIGINAL, sin opcion). DINAMICO: // se ofrece la fps FUENTE del item + las estandar MENORES (no "mentir" ofreciendo 60 en un icono de // 27). item.fps = fps fuente (banners 60; iconos = la real de index.json). 1 sola opcion -> nada. // (31-oct-2026, el usuario: «el seleccionar fps lo quitaría de todos, dejaría la animación por defecto») Sin elección de // fps: cada animado se baja a su velocidad original (sel.fps queda vacío → el fichero tal cual o el endpoint sin &fps). var srcFps = 0; exportOptions(sections, function (key, val, sels) { if (key === 'fmt') { var isStat = esFijo(val); if (sels.fps) sels.fps.row.style.display = isStat ? 'none' : ''; if (sels.frame) sels.frame.row.style.display = isStat ? '' : 'none'; } if (key === 'fps' && sels.fmt && sels.fmt.setOptLabel && !isPack && items[0].size && srcFps) { var est = Math.round(items[0].size * (+val) / srcFps); sels.fmt.setOptLabel('webp', 'WebP \u00b7 ' + T.orig + ' \u00b7 ' + (+val === srcFps ? '' : '~') + fmtBytes(est)); } if (opts.onChange) { opts.onChange(key, val, sels); } // la página tiene la última palabra }).then(function (sel) { var list = items; if (needScope && sel.scope !== 'all') { list = items.filter(function (x) { return sel.scope === 'anim' ? x.anim : !x.anim; }); } if (!list.length) return; // `applySel` puede devolver una PROMESA: los mapas resuelven el fichero de cada combinación // con un índice que a veces hay que pedir (el del tema elegido, para un ZIP de muchos). return Promise.resolve(opts.applySel ? opts.applySel(list, sel) : list).then(function (l) { l = (l && l.length) ? l : list; var mode = sel.mode || 'code'; if (isPack) savePack(l, mode, sel.fmt, zipName, sel.fps, opts.packBtn, opts.packProg); else saveOne(l[0], mode, sel.fmt, sel.fps, sel.frame); }); }).catch(function () {}); } window.BHX = { cfg: CFG, flow: flow, exportOptions: exportOptions, pick: pick, saveOne: saveOne, convItem: convItem, savePack: savePack, pickFrame: pickFrame, saveBlob: saveBlob, toFmt: toFmt, animUrl: animUrl, imgAvifUrl: imgAvifUrl, animFetch: animFetch, animFetchRetry: animFetchRetry, loadJSZip: loadJSZip, setPost: function (fn, extra) { postFn = fn || null; postQS = extra || ''; }, fmtBytes: fmtBytes, frameUrl: frameUrl, representativeBlob: representativeBlob, nameOf: nameOf }; })();

```json
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://brawlhub.gg/#organization","name":"BrawlHub GG","url":"https://brawlhub.gg","email":"contact@brawlhub.gg","logo":{"@type":"ImageObject","@id":"https://brawlhub.gg/#logo","url":"https://brawlhub.gg/assets/brawlhub-logo-web.webp","contentUrl":"https://brawlhub.gg/assets/brawlhub-logo-web.webp","caption":"BrawlHub GG","inLanguage":"ja","width":"512","height":"512"},"sameAs":["https://x.com/GGBrawlHub","https://www.tiktok.com/@brawlhub.gg","https://www.youtube.com/@BrawlHubGG","https://discord.gg/62EzTBHpM4"]},{"@type":"WebSite","@id":"https://brawlhub.gg/#website","url":"https://brawlhub.gg/","name":"BrawlHub GG","publisher":{"@id":"https://brawlhub.gg/#organization"},"inLanguage":"ja"},{"@type":"WebPage","@id":"https://brawlhub.gg/ja/resources/hypercharges/ruby/#webpage","url":"https://brawlhub.gg/ja/resources/hypercharges/ruby/","name":"Ruby - \u30aa\u30fc\u30eb\u30a4\u30f3\uff01 \u2014 \u30d6\u30ed\u30b9\u30bf \u30cf\u30a4\u30d1\u30fc\u30c1\u30e3\u30fc\u30b8 | BrawlHub GG","isPartOf":{"@id":"https://brawlhub.gg/#website"},"inLanguage":"ja","breadcrumb":{"@id":"https://brawlhub.gg/ja/resources/hypercharges/ruby/#breadcrumb"}},{"@type":"BreadcrumbList","@id":"https://brawlhub.gg/ja/resources/hypercharges/ruby/#breadcrumb","itemListElement":[{"@type":"ListItem","position":"1","name":"BrawlHub GG","item":"https://brawlhub.gg/ja/"},{"@type":"ListItem","position":"2","name":"\u30ea\u30bd\u30fc\u30b9","item":"https://brawlhub.gg/ja/resources/"},{"@type":"ListItem","position":"3","name":"\u30cf\u30a4\u30d1\u30fc\u30c1\u30e3\u30fc\u30b8","item":"https://brawlhub.gg/ja/resources/hypercharges/"},{"@type":"ListItem","position":"4","name":"Ruby - \u30aa\u30fc\u30eb\u30a4\u30f3\uff01","item":"https://brawlhub.gg/ja/resources/hypercharges/ruby/"}]}]}
```
