Allow importing multipart skins

This commit is contained in:
Brian Beck 2025-01-28 12:46:35 -08:00
parent 65e87d8783
commit 0af1b0123f
16 changed files with 278 additions and 68 deletions

View file

@ -162,14 +162,14 @@ export async function getSkinConfig() {
}, },
}, },
materials: { materials: {
lmale: [{ name: "base", label: "Warrior" }], lmale: [{ name: "base", label: "Warrior", fileSuffix: ".lmale" }],
mmale: [{ name: "base", label: "Warrior" }], mmale: [{ name: "base", label: "Warrior", fileSuffix: ".mmale" }],
hmale: [{ name: "base", label: "Warrior" }], hmale: [{ name: "base", label: "Warrior", fileSuffix: ".hmale" }],
lfemale: [{ name: "base", label: "Warrior" }], lfemale: [{ name: "base", label: "Warrior", fileSuffix: ".lfemale" }],
mfemale: [{ name: "base", label: "Warrior" }], mfemale: [{ name: "base", label: "Warrior", fileSuffix: ".mfemale" }],
lbioderm: [{ name: "base", label: "Warrior" }], lbioderm: [{ name: "base", label: "Warrior", fileSuffix: ".lbioderm" }],
mbioderm: [{ name: "base", label: "Warrior" }], mbioderm: [{ name: "base", label: "Warrior", fileSuffix: ".mbioderm" }],
hbioderm: [{ name: "base", label: "Warrior" }], hbioderm: [{ name: "base", label: "Warrior", fileSuffix: ".hbioderm" }],
disc: [ disc: [
{ name: "weapon_disc", label: "Weapon" }, { name: "weapon_disc", label: "Weapon" },
{ {
@ -184,6 +184,7 @@ export async function getSkinConfig() {
roughnessFactor: 1, roughnessFactor: 1,
frameCount: 6, frameCount: 6,
frameTimings: [21, 1, 1, 1, 1, 1], frameTimings: [21, 1, 1, 1, 1, 1],
optional: true,
}, },
], ],
chaingun: [{ label: "Chaingun", name: "weapon_chaingun" }], chaingun: [{ label: "Chaingun", name: "weapon_chaingun" }],
@ -192,8 +193,18 @@ export async function getSkinConfig() {
], ],
sniper: [ sniper: [
{ label: "Weapon", name: "weapon_sniper" }, { label: "Weapon", name: "weapon_sniper" },
{ label: "Green Light", name: "greenlight", hasDefault: false }, {
{ label: "Red Light", name: "lite_red", hasDefault: false }, label: "Green Light",
name: "greenlight",
hasDefault: false,
optional: true,
},
{
label: "Red Light",
name: "lite_red",
hasDefault: false,
optional: true,
},
], ],
plasmathrower: [ plasmathrower: [
{ {
@ -237,11 +248,16 @@ export async function getSkinConfig() {
metallicFactor: 0, metallicFactor: 0,
roughnessFactor: 1, roughnessFactor: 1,
size: [256, 128], size: [256, 128],
optional: true,
}, },
], ],
elf: [ elf: [
{ label: "Weapon", name: "weapon_elf", file: "weapon_elf" }, { label: "Weapon", name: "weapon_elf", file: "weapon_elf" },
{ label: "Glow", name: "weapon_elf0", file: "weapon_elf" }, {
label: "Glow",
name: "weapon_elf0",
file: "weapon_elf",
},
], ],
missile: [{ label: "Weapon", name: "weapon_missile" }], missile: [{ label: "Weapon", name: "weapon_missile" }],
mortar: [{ label: "Weapon", name: "weapon_mortar" }], mortar: [{ label: "Weapon", name: "weapon_mortar" }],
@ -273,6 +289,7 @@ export async function getSkinConfig() {
metallicFactor: 0, metallicFactor: 0,
roughnessFactor: 1, roughnessFactor: 1,
size: [256, 256], size: [256, 256],
optional: true,
}, },
], ],
vehicle_air_bomber: [ vehicle_air_bomber: [
@ -381,6 +398,7 @@ export async function getSkinConfig() {
metallicFactor: 0, metallicFactor: 0,
roughnessFactor: 1, roughnessFactor: 1,
size: [128, 128], size: [128, 128],
optional: true,
}, },
{ {
label: "Windshield Inner", label: "Windshield Inner",
@ -392,6 +410,7 @@ export async function getSkinConfig() {
metallicFactor: 0, metallicFactor: 0,
roughnessFactor: 1, roughnessFactor: 1,
size: [128, 128], size: [128, 128],
optional: true,
}, },
], ],
vehicle_grav_tank: [ vehicle_grav_tank: [
@ -446,6 +465,7 @@ export async function getSkinConfig() {
name: "Vehicle_Land_Assault_wheel", name: "Vehicle_Land_Assault_wheel",
file: "Vehicle_Land_Assault_Wheel", file: "Vehicle_Land_Assault_Wheel",
size: [512, 256], size: [512, 256],
optional: true,
}, },
], ],
}, },

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
self.__BUILD_MANIFEST=function(s){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[s,"static/chunks/e21e5bbe-b28e0079b469d4e8.js","static/chunks/ebc70433-4eccd1cb3af29a3e.js","static/chunks/6eb5140f-31a2b2da7903b885.js","static/chunks/85d7bc83-1ca530d7d3f44153.js","static/chunks/3a17f596-9aeae038dfa51955.js","static/chunks/f580fadb-2911e2fbf64aae5a.js","static/chunks/515-13ff0773d41722ae.js","static/chunks/pages/index-c0f57672b4d1826e.js"],"/_error":["static/chunks/pages/_error-54b9fcf45cb5bc62.js"],"/gallery":[s,"static/chunks/737a5600-aea383aaa2061cc6.js","static/chunks/918-3c6747f76df39072.js","static/css/922e89893536f2f9.css","static/chunks/pages/gallery-53e8b702048e0fa2.js"],sortedPages:["/","/_app","/_error","/gallery"]}}("static/chunks/cb355538-dbf1c108320fc6a1.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); self.__BUILD_MANIFEST=function(s){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[s,"static/chunks/e21e5bbe-b28e0079b469d4e8.js","static/chunks/ebc70433-4eccd1cb3af29a3e.js","static/chunks/6eb5140f-31a2b2da7903b885.js","static/chunks/85d7bc83-1ca530d7d3f44153.js","static/chunks/3a17f596-9aeae038dfa51955.js","static/chunks/f580fadb-2911e2fbf64aae5a.js","static/chunks/515-13ff0773d41722ae.js","static/chunks/pages/index-07380c07eee841be.js"],"/_error":["static/chunks/pages/_error-54b9fcf45cb5bc62.js"],"/gallery":[s,"static/chunks/737a5600-aea383aaa2061cc6.js","static/chunks/918-3c6747f76df39072.js","static/css/922e89893536f2f9.css","static/chunks/pages/gallery-53e8b702048e0fa2.js"],sortedPages:["/","/_app","/_error","/gallery"]}}("static/chunks/cb355538-dbf1c108320fc6a1.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -14,14 +14,15 @@ export type ModelMaterial = NonNullable<
>["materials"][number]; >["materials"][number];
export type MaterialDefinition = { export type MaterialDefinition = {
index?: number;
name: string; name: string;
label?: string; label?: string;
file?: string; file?: string;
fileSuffix?: string;
hasDefault?: boolean; hasDefault?: boolean;
size?: [number, number]; size?: [number, number];
hidden?: boolean; hidden?: boolean;
selectable?: boolean; selectable?: boolean;
optional?: boolean;
alphaMode?: "BLEND" | "MASK" | "OPAQUE"; alphaMode?: "BLEND" | "MASK" | "OPAQUE";
alphaCutoff?: number; alphaCutoff?: number;
baseColorFactor?: [number, number, number, number]; baseColorFactor?: [number, number, number, number];

View file

@ -15,9 +15,7 @@ export default function Materials() {
return ( return (
<> <>
{model.materials.map((material, i) => { {model.materials.map((material, i) => {
const materialDef = const materialDef = materialDefs[i];
materialDefs.find((materialDef) => materialDef.index === i) ??
materialDefs[i];
return ( return (
<Material <Material
key={material.name} key={material.name}

View file

@ -146,6 +146,7 @@ export default function WarriorProvider({ children }: { children: ReactNode }) {
actualModel, actualModel,
selectedAnimation selectedAnimation
); );
const [importedSkins, setImportedSkins] = useState(() => new Map());
const [skinImageUrls, setSkinImageUrls] = useState<Record<string, string[]>>( const [skinImageUrls, setSkinImageUrls] = useState<Record<string, string[]>>(
() => () =>
@ -191,6 +192,8 @@ export default function WarriorProvider({ children }: { children: ReactNode }) {
defaultSkinImageUrls, defaultSkinImageUrls,
slowModeEnabled, slowModeEnabled,
setSlowModeEnabled, setSlowModeEnabled,
importedSkins,
setImportedSkins,
}; };
}, [ }, [
selectedModel, selectedModel,
@ -211,6 +214,8 @@ export default function WarriorProvider({ children }: { children: ReactNode }) {
setSkinImageUrls, setSkinImageUrls,
defaultSkinImageUrls, defaultSkinImageUrls,
slowModeEnabled, slowModeEnabled,
importedSkins,
setImportedSkins,
]); ]);
useEffect(() => { useEffect(() => {

View file

@ -4,10 +4,10 @@ import { FaFolderOpen } from "react-icons/fa";
import { BsFillGrid3X3GapFill } from "react-icons/bs"; import { BsFillGrid3X3GapFill } from "react-icons/bs";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useTools from "./useTools"; import useTools from "./useTools";
import { detectFileType } from "./importUtils"; import { importMultipleFilesToModels } from "./importUtils";
const { publicRuntimeConfig } = getConfig(); const { publicRuntimeConfig } = getConfig();
const { defaultSkins, modelDefaults, materials } = publicRuntimeConfig; const { defaultSkins, modelDefaults /*materials*/ } = publicRuntimeConfig;
const baseManifestPath = `https://exogen.github.io/t2-skins`; const baseManifestPath = `https://exogen.github.io/t2-skins`;
const defaultCustomSkins = {}; const defaultCustomSkins = {};
@ -25,10 +25,12 @@ export default function WarriorSelector() {
setSelectedAnimation, setSelectedAnimation,
setSkinImageUrls, setSkinImageUrls,
setAnimationPaused, setAnimationPaused,
// importedSkins,
// setImportedSkins,
} = useWarrior(); } = useWarrior();
const { selectedMaterialIndex, setSelectedMaterialIndex } = useTools(); const { /*selectedMaterialIndex,*/ setSelectedMaterialIndex } = useTools();
const materialDefs = materials[actualModel]; // const materialDefs = materials[actualModel];
const materialDef = materialDefs[selectedMaterialIndex]; // const materialDef = materialDefs[selectedMaterialIndex];
const [customSkins, setCustomSkins] = const [customSkins, setCustomSkins] =
useState<Record<string, string[]>>(defaultCustomSkins); useState<Record<string, string[]>>(defaultCustomSkins);
const [newSkins, setNewSkins] = const [newSkins, setNewSkins] =
@ -254,40 +256,27 @@ export default function WarriorSelector() {
<input <input
ref={fileInputRef} ref={fileInputRef}
onChange={async (event) => { onChange={async (event) => {
const imageUrl = await new Promise<string>((resolve, reject) => { const foundModels = await importMultipleFilesToModels(
const inputFile = event.target.files?.[0]; event.target.files ?? []
if (inputFile) { );
const fileType = detectFileType(inputFile); const selectedModelSkins = foundModels.get(actualModel);
switch (fileType) { if (selectedModelSkins) {
case "png": { const skins = Array.from(selectedModelSkins.values());
const reader = new FileReader(); for (const skin of skins) {
reader.addEventListener("load", (event) => { if (skin.isComplete) {
resolve(event.target?.result as string); setSelectedSkin(null);
}); setSelectedSkinSection(null);
reader.readAsDataURL(inputFile); setSkinImageUrls(
break; Object.fromEntries(skin.materials.entries())
} );
// case "zip": break;
// case "vl2": {
// const skins = await readZipFile(inputFile);
// if (skins.length) {
// resolve(skins[0].imageUrl);
// }
// }
} }
} else {
reject(new Error("No input file provided."));
} }
}); }
setSelectedSkin(null);
setSelectedSkinSection(null);
setSkinImageUrls({
[materialDef.file ?? materialDef.name]: [imageUrl],
});
}} }}
type="file" type="file"
// accept=".png, image/png, .vl2, .zip, application/zip, application/zip-compressed" accept=".png, image/png, .vl2, .zip, application/zip, application/zip-compressed"
accept=".png, image/png" multiple
hidden hidden
/> />
</div> </div>

View file

@ -1,21 +1,45 @@
import JSZip from "jszip"; import JSZip from "jszip";
import getConfig from "next/config";
type MaterialDefinition = {
name: string;
file?: string;
fileSuffix?: string;
hidden?: boolean;
selectable?: boolean;
optional?: boolean;
};
const { publicRuntimeConfig } = getConfig();
const materialMap: Record<string, MaterialDefinition[]> =
publicRuntimeConfig.materials;
const importedSkinsByModel = new Map();
export function clearImportedSkins() {
importedSkinsByModel.clear();
}
const ignoreFilePattern = /^(\.|__MACOSX)/;
export async function readZipFile(inputFile: File) { export async function readZipFile(inputFile: File) {
const content = await JSZip.loadAsync(inputFile); const content = await JSZip.loadAsync(inputFile);
console.log("files", content.files);
const skins = await Promise.all( const skins = await Promise.all(
Object.entries(content.files).map(async ([path, file]) => { Object.entries(content.files).map(async ([path, file]) => {
const match = /([^/]+)\.([lmh](?:male|female|bioderm))\.png$/g.exec(path); if (!ignoreFilePattern.test(path)) {
if (match) { const match = /\.png$/i.exec(path);
const base64string = await file.async("base64"); if (match) {
return { const base64string = await file.async("base64");
name: match[1], return {
model: match[2], path,
imageUrl: `data:image/png;base64,${base64string}`, imageUrl: `data:image/png;base64,${base64string}`,
}; };
}
} }
}) })
); );
return skins.filter(Boolean); return skins.filter((x): x is NonNullable<typeof x> => Boolean(x));
} }
export function detectFileType(file: File) { export function detectFileType(file: File) {
@ -28,4 +52,177 @@ export function detectFileType(file: File) {
} }
} }
export function nameToMaterial(filename: string) {} export async function readImageFile(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
if (typeof event.target?.result === "string") {
resolve(event.target.result);
} else {
reject();
}
});
reader.addEventListener("error", (event) => {
reject();
});
reader.readAsDataURL(file);
});
}
export async function readMultipleFiles(fileList: FileList | File[]) {
const files = await Promise.all(
Array.from(fileList).map(async (file) => {
if (ignoreFilePattern.test(file.name)) {
return null;
}
const fileType = detectFileType(file);
switch (fileType) {
case "zip":
case "vl2": {
const match = file.name.match(/^(.+)\.(zip|vl2)$/i);
const name = match ? match[1] : file.name;
return (await readZipFile(file)).map(
(imageFile: { path: string; imageUrl: string }) => ({
...imageFile,
path: `${file.name}/${imageFile.path}`,
name,
})
);
}
case "png":
return {
path: file.name,
imageUrl: await readImageFile(file),
name: null,
};
default:
return null;
}
})
);
return files.flat().filter((x): x is NonNullable<typeof x> => Boolean(x));
}
function createReverseFileMap() {
const map = new Map();
for (const modelName in materialMap) {
materialMap[modelName].forEach((material, i) => {
let filename;
if (material.fileSuffix) {
filename = material.fileSuffix;
} else if (
material.selectable !== false &&
material.hidden !== true &&
(material.file || material.name)
) {
filename = material.file || material.name;
}
if (filename) {
const models = map.get(filename) ?? [];
models.push({ modelName, material, index: i });
map.set(filename, models);
}
});
}
return map;
}
let pathToModelMap: Map<
string,
Array<{
modelName: string;
material: MaterialDefinition;
index: number;
}>
>;
function pathToModels(path: string, skinName: string | null = null) {
if (!pathToModelMap) {
pathToModelMap = createReverseFileMap();
}
const basename = path.split("/").slice(-1)[0];
const match = basename.match(/^(.+)\.(PNG|png)$/);
if (match) {
const nameWithoutExtension = match[1];
const parts = nameWithoutExtension.split(".");
if (parts.length > 1) {
const key = `.${parts[parts.length - 1]}`;
const models = pathToModelMap.get(key);
if (models) {
return {
path,
basename,
nameWithoutExtension,
extension: match[2],
skinName: parts.slice(0, parts.length - 1).join("."),
models,
};
}
} else {
const key = parts[0];
const models = pathToModelMap.get(key);
if (models) {
return {
path,
basename,
nameWithoutExtension,
extension: match[2],
skinName,
models,
};
}
}
}
return null;
}
type Skin = {
name: string | null;
isComplete: null | boolean;
materials: Map<string, string[]>;
};
export function fileArrayToModels(
files: Array<{ path: string; name: string | null; imageUrl: string }>
) {
const foundModels: Map<string, Map<string | null, Skin>> = new Map();
files.forEach((file) => {
const fileInfo = pathToModels(file.path, file.name);
if (fileInfo) {
fileInfo.models.forEach((model) => {
const skinsByName: Map<string | null, Skin> =
foundModels.get(model.modelName) ?? new Map();
const skinMaterials: Skin = skinsByName.get(fileInfo.skinName) ?? {
name: fileInfo.skinName,
isComplete: null,
materials: new Map(),
};
skinMaterials.materials.set(
model.material.file ?? model.material.name,
[file.imageUrl]
);
skinsByName.set(fileInfo.skinName, skinMaterials);
foundModels.set(model.modelName, skinsByName);
});
}
});
foundModels.forEach((skinsByName, modelName) => {
const requiredMaterials = materialMap[modelName].filter(
(material) =>
material.selectable !== false &&
material.hidden !== true &&
material.optional !== true
);
skinsByName.forEach((skin) => {
skin.isComplete = requiredMaterials.every((material) =>
skin.materials.has(material.file ?? material.name)
);
});
});
return foundModels;
}
export async function importMultipleFilesToModels(fileList: FileList | File[]) {
const imageFiles = await readMultipleFiles(fileList);
return fileArrayToModels(imageFiles);
}