Repository navigation
feat(skins): detectar el modelo de la skin al subirla #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
staFF6773
merged 2 commits into
CubicLauncherDevs:develop
from
MopicMP:feat/detectar-modelo-skin
Oct 21, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| /** | ||
| * Detecta si una skin fue dibujada para el modelo clásico (Steve, brazos de | ||
| * 4 px) o delgado (Alex, brazos de 3 px), mirando la propia imagen. | ||
| * | ||
| * El modelo delgado deja sin usar dos columnas en la cara de atrás de cada | ||
| * brazo y dos en la parte de abajo de la mano. Si una skin delgada se sube como | ||
| * clásica, el juego lee esas columnas vacías y, como dibuja opaca la capa base, | ||
| * aparece una franja (casi siempre negra) en la parte de atrás del brazo. | ||
| * | ||
| * Solo se decide con pruebas en la imagen. Si las caras que distinguen los dos | ||
| * modelos están vacías (brazo sin dibujar, transparente o imagen incompleta), se | ||
| * devuelve `null` y quien llama conserva el modelo elegido. | ||
| */ | ||
|
|
||
| export type SkinModel = "classic" | "slim"; | ||
|
|
||
| interface Zone { | ||
| x: number; | ||
| y: number; | ||
| w: number; | ||
| h: number; | ||
| } | ||
|
|
||
| /** | ||
| * Una cara del brazo cuyo ancho cambia entre modelos: la parte que ambos dibujan | ||
| * y las columnas que solo existen en el clásico. | ||
| */ | ||
| interface ArmFace { | ||
| shared: Zone; | ||
| classicOnly: Zone; | ||
| } | ||
|
|
||
| /** Por debajo de este alfa un píxel cuenta como vacío: el antialiasing deja restos. */ | ||
| const CLEAR_ALPHA = 8; | ||
|
|
||
| /** Cara de atrás y parte de abajo de cada brazo, en píxeles de una skin de 64×64. */ | ||
| const ARM_FACES: readonly ArmFace[] = [ | ||
| // brazo derecho, cara de atrás | ||
| { | ||
| shared: { x: 52, y: 20, w: 2, h: 12 }, | ||
| classicOnly: { x: 54, y: 20, w: 2, h: 12 }, | ||
| }, | ||
| // brazo derecho, parte de abajo | ||
| { | ||
| shared: { x: 48, y: 16, w: 2, h: 4 }, | ||
| classicOnly: { x: 50, y: 16, w: 2, h: 4 }, | ||
| }, | ||
| // brazo izquierdo, cara de atrás | ||
| { | ||
| shared: { x: 44, y: 52, w: 2, h: 12 }, | ||
| classicOnly: { x: 46, y: 52, w: 2, h: 12 }, | ||
| }, | ||
| // brazo izquierdo, parte de abajo | ||
| { | ||
| shared: { x: 40, y: 48, w: 2, h: 4 }, | ||
| classicOnly: { x: 42, y: 48, w: 2, h: 4 }, | ||
| }, | ||
| ]; | ||
|
|
||
| /** | ||
| * @param rgba píxeles RGBA de la imagen completa, fila por fila | ||
| * @param width ancho real de la imagen (64, 128, ... para skins HD) | ||
| * @param height alto real de la imagen | ||
| */ | ||
| export function detectSkinModel( | ||
| rgba: ArrayLike<number>, | ||
| width: number, | ||
| height: number, | ||
| ): SkinModel | null { | ||
| const scale = Math.max(1, Math.round(width / 64)); | ||
| // El formato de 64×32 (anterior a 1.8) solo tiene brazos de 4 px. | ||
| if (Math.round(height / scale) < 64) return "classic"; | ||
|
|
||
| const alphaAt = (x: number, y: number): number => { | ||
| // Centro del bloque en skins HD, para que un píxel suelto en el borde no decida. | ||
| const px = Math.min(width - 1, x * scale + (scale >> 1)); | ||
| const py = Math.min(height - 1, y * scale + (scale >> 1)); | ||
| return rgba[(py * width + px) * 4 + 3] ?? 0; | ||
| }; | ||
| const hasPixels = (zone: Zone): boolean => { | ||
| for (let y = zone.y; y < zone.y + zone.h; y++) { | ||
| for (let x = zone.x; x < zone.x + zone.w; x++) { | ||
| if (alphaAt(x, y) >= CLEAR_ALPHA) return true; | ||
| } | ||
| } | ||
| return false; | ||
| }; | ||
|
|
||
| // Algo dibujado donde el delgado no llega solo puede ser de un brazo clásico. | ||
| if (ARM_FACES.some((face) => hasPixels(face.classicOnly))) return "classic"; | ||
| // Una cara dibujada que termina antes de esas columnas es de un brazo delgado. | ||
| if (ARM_FACES.some((face) => hasPixels(face.shared))) return "slim"; | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Igual que {@link detectSkinModel}, a partir de una URL que el canvas pueda leer | ||
| * (por ejemplo, el data URL de la vista previa). Devuelve `null` si la imagen no | ||
| * se puede leer o no alcanza para decidir, para que quien llama conserve el | ||
| * modelo elegido. | ||
| */ | ||
| export async function detectSkinModelFromUrl( | ||
| url: string, | ||
| ): Promise<SkinModel | null> { | ||
| try { | ||
| const image = await loadImage(url); | ||
| const canvas = document.createElement("canvas"); | ||
| canvas.width = image.naturalWidth; | ||
| canvas.height = image.naturalHeight; | ||
| const context = canvas.getContext("2d", { willReadFrequently: true }); | ||
| if (!context) return null; | ||
| context.drawImage(image, 0, 0); | ||
| const { data } = context.getImageData( | ||
| 0, | ||
| 0, | ||
| canvas.width, | ||
| canvas.height, | ||
| ); | ||
| return detectSkinModel(data, canvas.width, canvas.height); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function loadImage(url: string): Promise<HTMLImageElement> { | ||
| return new Promise((resolve, reject) => { | ||
| const image = new Image(); | ||
| image.onload = () => resolve(image); | ||
| image.onerror = () => reject(new Error("No se pudo leer la skin")); | ||
| image.src = url; | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { detectSkinModel } from "../../../src/lib/utils/skinModel.ts"; | ||
|
|
||
| // Skin sintética: se pintan rectángulos opacos en coordenadas de 64×64. | ||
| function skin({ width = 64, height = 64, paint = [], alpha = 255 } = {}) { | ||
| const scale = width / 64; | ||
| const rgba = new Uint8ClampedArray(width * height * 4); | ||
| for (const { x, y, w, h } of paint) { | ||
| for (let py = y * scale; py < (y + h) * scale; py++) { | ||
| for (let px = x * scale; px < (x + w) * scale; px++) { | ||
| rgba[(py * width + px) * 4 + 3] = alpha; | ||
| } | ||
| } | ||
| } | ||
| return { rgba, width, height }; | ||
| } | ||
|
|
||
| const detect = ({ rgba, width, height }) => | ||
| detectSkinModel(rgba, width, height); | ||
|
|
||
| // Brazos tal como los dibuja cada modelo (cara de arriba, abajo y las cuatro laterales). | ||
| const CLASSIC_ARMS = [ | ||
| { x: 44, y: 16, w: 8, h: 4 }, | ||
| { x: 40, y: 20, w: 16, h: 12 }, | ||
| { x: 36, y: 48, w: 8, h: 4 }, | ||
| { x: 32, y: 52, w: 16, h: 12 }, | ||
| ]; | ||
| const SLIM_ARMS = [ | ||
| { x: 44, y: 16, w: 6, h: 4 }, | ||
| { x: 40, y: 20, w: 14, h: 12 }, | ||
| { x: 36, y: 48, w: 6, h: 4 }, | ||
| { x: 32, y: 52, w: 14, h: 12 }, | ||
| ]; | ||
|
|
||
| test("a skin drawn with 3 px arms is detected as slim", () => { | ||
| expect(detect(skin({ paint: SLIM_ARMS }))).toBe("slim"); | ||
| }); | ||
|
|
||
| test("a skin drawn with 4 px arms is detected as classic", () => { | ||
| expect(detect(skin({ paint: CLASSIC_ARMS }))).toBe("classic"); | ||
| }); | ||
|
|
||
| test("one drawn column in any slim-only zone means classic", () => { | ||
| for (const zone of [ | ||
| { x: 55, y: 25, w: 1, h: 1 }, | ||
| { x: 51, y: 17, w: 1, h: 1 }, | ||
| { x: 46, y: 60, w: 1, h: 1 }, | ||
| { x: 43, y: 49, w: 1, h: 1 }, | ||
| ]) { | ||
| expect(detect(skin({ paint: [...SLIM_ARMS, zone] }))).toBe("classic"); | ||
| } | ||
| }); | ||
|
|
||
| test("antialiasing leftovers in the slim-only zones do not count", () => { | ||
| const image = skin({ paint: SLIM_ARMS }); | ||
| const stray = skin({ paint: [{ x: 54, y: 20, w: 2, h: 12 }], alpha: 3 }); | ||
| for (let i = 3; i < image.rgba.length; i += 4) { | ||
| image.rgba[i] = Math.max(image.rgba[i], stray.rgba[i]); | ||
| } | ||
| expect(detect(image)).toBe("slim"); | ||
| }); | ||
|
|
||
| test("without enough pixels to decide, no model is returned", () => { | ||
| // Imagen vacía. | ||
| expect(detect(skin())).toBe(null); | ||
| // Brazos dibujados, pero con la cara de atrás y la de abajo transparentes: | ||
| // justo las caras que distinguen un modelo del otro. | ||
| const transparentBacks = [ | ||
| { x: 44, y: 16, w: 3, h: 4 }, | ||
| { x: 40, y: 20, w: 11, h: 12 }, | ||
| { x: 36, y: 48, w: 3, h: 4 }, | ||
| { x: 32, y: 52, w: 11, h: 12 }, | ||
| ]; | ||
| expect(detect(skin({ paint: transparentBacks }))).toBe(null); | ||
| }); | ||
|
|
||
| test("one drawn arm is enough to decide", () => { | ||
| const leftSlimArm = [ | ||
| { x: 36, y: 48, w: 6, h: 4 }, | ||
| { x: 32, y: 52, w: 14, h: 12 }, | ||
| ]; | ||
| expect(detect(skin({ paint: leftSlimArm }))).toBe("slim"); | ||
| const rightClassicArm = [ | ||
| { x: 44, y: 16, w: 8, h: 4 }, | ||
| { x: 40, y: 20, w: 16, h: 12 }, | ||
| ]; | ||
| expect(detect(skin({ paint: rightClassicArm }))).toBe("classic"); | ||
| }); | ||
|
|
||
| test("legacy 64x32 skins are always classic", () => { | ||
| const legacy = skin({ | ||
| height: 32, | ||
| paint: [ | ||
| { x: 44, y: 16, w: 6, h: 4 }, | ||
| { x: 40, y: 20, w: 14, h: 12 }, | ||
| ], | ||
| }); | ||
| expect(detect(legacy)).toBe("classic"); | ||
| }); | ||
|
|
||
| test("HD skins are read with the same rule", () => { | ||
| expect(detect(skin({ width: 128, height: 128, paint: SLIM_ARMS }))).toBe( | ||
| "slim", | ||
| ); | ||
| expect(detect(skin({ width: 128, height: 128, paint: CLASSIC_ARMS }))).toBe( | ||
| "classic", | ||
| ); | ||
| expect(detect(skin({ width: 128, height: 64, paint: SLIM_ARMS }))).toBe( | ||
| "classic", | ||
| ); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.