mirror of
https://github.com/exogen/t2-model-skinner.git
synced 2026-04-23 13:25:28 +00:00
Allow importing multipart skins
This commit is contained in:
parent
65e87d8783
commit
0af1b0123f
16 changed files with 278 additions and 68 deletions
|
|
@ -162,14 +162,14 @@ export async function getSkinConfig() {
|
|||
},
|
||||
},
|
||||
materials: {
|
||||
lmale: [{ name: "base", label: "Warrior" }],
|
||||
mmale: [{ name: "base", label: "Warrior" }],
|
||||
hmale: [{ name: "base", label: "Warrior" }],
|
||||
lfemale: [{ name: "base", label: "Warrior" }],
|
||||
mfemale: [{ name: "base", label: "Warrior" }],
|
||||
lbioderm: [{ name: "base", label: "Warrior" }],
|
||||
mbioderm: [{ name: "base", label: "Warrior" }],
|
||||
hbioderm: [{ name: "base", label: "Warrior" }],
|
||||
lmale: [{ name: "base", label: "Warrior", fileSuffix: ".lmale" }],
|
||||
mmale: [{ name: "base", label: "Warrior", fileSuffix: ".mmale" }],
|
||||
hmale: [{ name: "base", label: "Warrior", fileSuffix: ".hmale" }],
|
||||
lfemale: [{ name: "base", label: "Warrior", fileSuffix: ".lfemale" }],
|
||||
mfemale: [{ name: "base", label: "Warrior", fileSuffix: ".mfemale" }],
|
||||
lbioderm: [{ name: "base", label: "Warrior", fileSuffix: ".lbioderm" }],
|
||||
mbioderm: [{ name: "base", label: "Warrior", fileSuffix: ".mbioderm" }],
|
||||
hbioderm: [{ name: "base", label: "Warrior", fileSuffix: ".hbioderm" }],
|
||||
disc: [
|
||||
{ name: "weapon_disc", label: "Weapon" },
|
||||
{
|
||||
|
|
@ -184,6 +184,7 @@ export async function getSkinConfig() {
|
|||
roughnessFactor: 1,
|
||||
frameCount: 6,
|
||||
frameTimings: [21, 1, 1, 1, 1, 1],
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
chaingun: [{ label: "Chaingun", name: "weapon_chaingun" }],
|
||||
|
|
@ -192,8 +193,18 @@ export async function getSkinConfig() {
|
|||
],
|
||||
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: [
|
||||
{
|
||||
|
|
@ -237,11 +248,16 @@ export async function getSkinConfig() {
|
|||
metallicFactor: 0,
|
||||
roughnessFactor: 1,
|
||||
size: [256, 128],
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
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" }],
|
||||
mortar: [{ label: "Weapon", name: "weapon_mortar" }],
|
||||
|
|
@ -273,6 +289,7 @@ export async function getSkinConfig() {
|
|||
metallicFactor: 0,
|
||||
roughnessFactor: 1,
|
||||
size: [256, 256],
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
vehicle_air_bomber: [
|
||||
|
|
@ -381,6 +398,7 @@ export async function getSkinConfig() {
|
|||
metallicFactor: 0,
|
||||
roughnessFactor: 1,
|
||||
size: [128, 128],
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
label: "Windshield Inner",
|
||||
|
|
@ -392,6 +410,7 @@ export async function getSkinConfig() {
|
|||
metallicFactor: 0,
|
||||
roughnessFactor: 1,
|
||||
size: [128, 128],
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
vehicle_grav_tank: [
|
||||
|
|
@ -446,6 +465,7 @@ export async function getSkinConfig() {
|
|||
name: "Vehicle_Land_Assault_wheel",
|
||||
file: "Vehicle_Land_Assault_Wheel",
|
||||
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
|
|
@ -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();
|
||||
2
docs/_next/static/chunks/pages/index-07380c07eee841be.js
Normal file
2
docs/_next/static/chunks/pages/index-07380c07eee841be.js
Normal file
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
|
|
@ -14,14 +14,15 @@ export type ModelMaterial = NonNullable<
|
|||
>["materials"][number];
|
||||
|
||||
export type MaterialDefinition = {
|
||||
index?: number;
|
||||
name: string;
|
||||
label?: string;
|
||||
file?: string;
|
||||
fileSuffix?: string;
|
||||
hasDefault?: boolean;
|
||||
size?: [number, number];
|
||||
hidden?: boolean;
|
||||
selectable?: boolean;
|
||||
optional?: boolean;
|
||||
alphaMode?: "BLEND" | "MASK" | "OPAQUE";
|
||||
alphaCutoff?: number;
|
||||
baseColorFactor?: [number, number, number, number];
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ export default function Materials() {
|
|||
return (
|
||||
<>
|
||||
{model.materials.map((material, i) => {
|
||||
const materialDef =
|
||||
materialDefs.find((materialDef) => materialDef.index === i) ??
|
||||
materialDefs[i];
|
||||
const materialDef = materialDefs[i];
|
||||
return (
|
||||
<Material
|
||||
key={material.name}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export default function WarriorProvider({ children }: { children: ReactNode }) {
|
|||
actualModel,
|
||||
selectedAnimation
|
||||
);
|
||||
const [importedSkins, setImportedSkins] = useState(() => new Map());
|
||||
|
||||
const [skinImageUrls, setSkinImageUrls] = useState<Record<string, string[]>>(
|
||||
() =>
|
||||
|
|
@ -191,6 +192,8 @@ export default function WarriorProvider({ children }: { children: ReactNode }) {
|
|||
defaultSkinImageUrls,
|
||||
slowModeEnabled,
|
||||
setSlowModeEnabled,
|
||||
importedSkins,
|
||||
setImportedSkins,
|
||||
};
|
||||
}, [
|
||||
selectedModel,
|
||||
|
|
@ -211,6 +214,8 @@ export default function WarriorProvider({ children }: { children: ReactNode }) {
|
|||
setSkinImageUrls,
|
||||
defaultSkinImageUrls,
|
||||
slowModeEnabled,
|
||||
importedSkins,
|
||||
setImportedSkins,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { FaFolderOpen } from "react-icons/fa";
|
|||
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useTools from "./useTools";
|
||||
import { detectFileType } from "./importUtils";
|
||||
import { importMultipleFilesToModels } from "./importUtils";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { defaultSkins, modelDefaults, materials } = publicRuntimeConfig;
|
||||
const { defaultSkins, modelDefaults /*materials*/ } = publicRuntimeConfig;
|
||||
|
||||
const baseManifestPath = `https://exogen.github.io/t2-skins`;
|
||||
const defaultCustomSkins = {};
|
||||
|
|
@ -25,10 +25,12 @@ export default function WarriorSelector() {
|
|||
setSelectedAnimation,
|
||||
setSkinImageUrls,
|
||||
setAnimationPaused,
|
||||
// importedSkins,
|
||||
// setImportedSkins,
|
||||
} = useWarrior();
|
||||
const { selectedMaterialIndex, setSelectedMaterialIndex } = useTools();
|
||||
const materialDefs = materials[actualModel];
|
||||
const materialDef = materialDefs[selectedMaterialIndex];
|
||||
const { /*selectedMaterialIndex,*/ setSelectedMaterialIndex } = useTools();
|
||||
// const materialDefs = materials[actualModel];
|
||||
// const materialDef = materialDefs[selectedMaterialIndex];
|
||||
const [customSkins, setCustomSkins] =
|
||||
useState<Record<string, string[]>>(defaultCustomSkins);
|
||||
const [newSkins, setNewSkins] =
|
||||
|
|
@ -254,40 +256,27 @@ export default function WarriorSelector() {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
onChange={async (event) => {
|
||||
const imageUrl = await new Promise<string>((resolve, reject) => {
|
||||
const inputFile = event.target.files?.[0];
|
||||
if (inputFile) {
|
||||
const fileType = detectFileType(inputFile);
|
||||
switch (fileType) {
|
||||
case "png": {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (event) => {
|
||||
resolve(event.target?.result as string);
|
||||
});
|
||||
reader.readAsDataURL(inputFile);
|
||||
break;
|
||||
}
|
||||
// case "zip":
|
||||
// case "vl2": {
|
||||
// const skins = await readZipFile(inputFile);
|
||||
// if (skins.length) {
|
||||
// resolve(skins[0].imageUrl);
|
||||
// }
|
||||
// }
|
||||
const foundModels = await importMultipleFilesToModels(
|
||||
event.target.files ?? []
|
||||
);
|
||||
const selectedModelSkins = foundModels.get(actualModel);
|
||||
if (selectedModelSkins) {
|
||||
const skins = Array.from(selectedModelSkins.values());
|
||||
for (const skin of skins) {
|
||||
if (skin.isComplete) {
|
||||
setSelectedSkin(null);
|
||||
setSelectedSkinSection(null);
|
||||
setSkinImageUrls(
|
||||
Object.fromEntries(skin.materials.entries())
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
reject(new Error("No input file provided."));
|
||||
}
|
||||
});
|
||||
setSelectedSkin(null);
|
||||
setSelectedSkinSection(null);
|
||||
setSkinImageUrls({
|
||||
[materialDef.file ?? materialDef.name]: [imageUrl],
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="file"
|
||||
// accept=".png, image/png, .vl2, .zip, application/zip, application/zip-compressed"
|
||||
accept=".png, image/png"
|
||||
accept=".png, image/png, .vl2, .zip, application/zip, application/zip-compressed"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,45 @@
|
|||
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) {
|
||||
const content = await JSZip.loadAsync(inputFile);
|
||||
console.log("files", content.files);
|
||||
const skins = await Promise.all(
|
||||
Object.entries(content.files).map(async ([path, file]) => {
|
||||
const match = /([^/]+)\.([lmh](?:male|female|bioderm))\.png$/g.exec(path);
|
||||
if (match) {
|
||||
const base64string = await file.async("base64");
|
||||
return {
|
||||
name: match[1],
|
||||
model: match[2],
|
||||
imageUrl: `data:image/png;base64,${base64string}`,
|
||||
};
|
||||
if (!ignoreFilePattern.test(path)) {
|
||||
const match = /\.png$/i.exec(path);
|
||||
if (match) {
|
||||
const base64string = await file.async("base64");
|
||||
return {
|
||||
path,
|
||||
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) {
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue