---
title: "Ranked — Brawl Stars | BrawlHub GG"
description: "Ranked — Brawl Stars, straight from the game, at original resolution in PNG or WebP."
url: https://brawlhub.gg/resources/ranked/
lang: en-GB
---

Ranked

# Resources

Supercell’s official assets for fans are in its [Fan Kit](https://fankit.supercell.com/) . This is a fan-made collection of what appears in the game. Are you from Supercell and want us to change or remove something? [Write to us](https://brawlhub.gg/contact/?motivo=otro) and we’ll do it.

Bronze

Bronze

Silver

Silver

Gold

Gold

Diamond

Diamond

Mythic

Mythic

Legendary

Legendary

Masters

Masters

Pro

Pro

Bronze 2025

Bronze 2025

Silver 2025

Silver 2025

Gold 2025

Gold 2025

Diamond 2025

Diamond 2025

Mythic 2025

Mythic 2025

Legendary 2025

Legendary 2025

Masters 2025

Masters 2025

Pro 2025

Pro 2025

Bronze 2024

Bronze 2024

Silver 2024

Silver 2024

Gold 2024

Gold 2024

Diamond 2024

Diamond 2024

Mythic 2024

Mythic 2024

Legendary 2024

Legendary 2024

Masters 2024

Masters 2024

No results.

' + '
'); 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 }; })();

Ranked

Format

WebP

Resolution

—

Size

Frames

Added

Type

```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":"en-GB","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":"en-GB"},{"@type":"CollectionPage","@id":"https://brawlhub.gg/resources/ranked/#webpage","url":"https://brawlhub.gg/resources/ranked/","name":"Ranked \u2014 Brawl Stars | BrawlHub GG","isPartOf":{"@id":"https://brawlhub.gg/#website"},"inLanguage":"en-GB","breadcrumb":{"@id":"https://brawlhub.gg/resources/ranked/#breadcrumb"}},{"@type":"BreadcrumbList","@id":"https://brawlhub.gg/resources/ranked/#breadcrumb","itemListElement":[{"@type":"ListItem","position":"1","name":"BrawlHub GG","item":"https://brawlhub.gg/"},{"@type":"ListItem","position":"2","name":"Resources","item":"https://brawlhub.gg/resources/"},{"@type":"ListItem","position":"3","name":"Ranked","item":"https://brawlhub.gg/resources/ranked/"}]}]}
```
