1219 lines
65 KiB
JavaScript
1219 lines
65 KiB
JavaScript
"use strict";
|
||
|
||
// Costume Studio (Costume tab): the hero body (sp2_human_man / _woman) in its rest pose, a 3D model
|
||
// imported onto it (.glb / .gltf / .obj) with weights from its own bones or from the body, played
|
||
// with any animation, and saved as a costume mesh (.msh + material script + .dds). "Body for
|
||
// Blender" writes what is shown as a rigged .glb to model on. The maths is in costumekit.js.
|
||
|
||
(() => {
|
||
const CK = CostumeKit;
|
||
const COS = {
|
||
src: null,
|
||
rows: [],
|
||
kind: "msh",
|
||
sex: "man",
|
||
sk: null,
|
||
P: null, // rest pose
|
||
surface: null, // the hero body's surface (weights for the costume)
|
||
base: [], // the hero's own meshes
|
||
worn: [], // meshes put on from the list
|
||
anims: [],
|
||
ani: null, // { data, map, name }
|
||
time: 0,
|
||
playing: false,
|
||
tpose: true,
|
||
file: null, // { path, name, data (loadGltf / parseObj), meshNames }
|
||
mats: [], // per file material: { name, color, image: { name, bytes, mime }, img, tex }
|
||
out: {}, // part → CK.build result (body, hand, helmet, cloak)
|
||
views: {}, // part → the mesh in the view
|
||
opts: { parts: {}, hideFace: true, weights: "auto", bone: 0, scale: 0, offset: [0, 0, 0], rotY: 0, maxBones: CK.MAX_BONES, include: null, name: "", hw: true, outline: true, texMax: 1024, splitHands: true, outfit: false, skinTol: 1 },
|
||
dirty: false,
|
||
};
|
||
window.COS = COS;
|
||
try {
|
||
COS.texDir = localStorage.getItem("cos:texDir") || null; // the last textures folder picked: looked in again
|
||
} catch {}
|
||
let view = null;
|
||
let kit = null;
|
||
|
||
const stem = (p) => p.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
|
||
const dirOf = (p) => p.replace(/[\\/][^\\/]*$/, "");
|
||
const visible = () => $("view-cos").classList.contains("on");
|
||
const toggled = (id) => $(id).classList.contains("on");
|
||
// the costume parts a mesh of the file can be: what it is in the game and which hero mesh it replaces
|
||
const PARTS = {
|
||
body: { label: "Body", ini: "armor", key: "body_mesh", hides: /(^|_)body/i },
|
||
hand: { label: "Hands", ini: "armor", key: "hand_mesh", hides: /hand/i },
|
||
helmet: { label: "Helmet / head", ini: "helmet", key: "helmet_mesh", hides: /hair/i },
|
||
cloak: { label: "Cloak / back", ini: "cloak", key: "cloak_mesh", hides: null },
|
||
skip: { label: "— leave out —" },
|
||
};
|
||
const PART_ORDER = ["body", "hand", "helmet", "cloak"];
|
||
/** The file name of a part: <name>_body_01, <name>_hand_01 … */
|
||
const outName = (part) => `${COS.opts.name}_${part}_01`;
|
||
/** The first built part (the materials / report card start from it). */
|
||
const anyOut = () => PART_ORDER.map((p) => COS.out[p]).find(Boolean) || null;
|
||
|
||
function ensureView() {
|
||
if (view) return view;
|
||
try {
|
||
view = new GL3D.View($("cos-canvas"));
|
||
} catch (err) {
|
||
toast(`3D view unavailable: ${err.message}`, true);
|
||
return null;
|
||
}
|
||
kit = MeshKit.create(view, () => COS.src, { prepareTexture: skinTone });
|
||
COS.view = view;
|
||
return view;
|
||
}
|
||
/**
|
||
* The hero textures keep the skin as transparent black (the game blends in #MODEL_COLOR there:
|
||
* blend_texture_alpha texture tfactor); the preview does the same, else the skin looks black.
|
||
*/
|
||
function skinTone({ img }) {
|
||
const c = COS.skin || [243, 210, 143];
|
||
let d;
|
||
if (img instanceof ImageData) d = img;
|
||
else {
|
||
const cv = new OffscreenCanvas(img.width, img.height);
|
||
const g = cv.getContext("2d");
|
||
g.drawImage(img, 0, 0);
|
||
d = g.getImageData(0, 0, img.width, img.height);
|
||
}
|
||
const p = d.data;
|
||
let clear = 0, dark = 0;
|
||
for (let i = 0; i < p.length; i += 4) if (p[i + 3] < 128) (clear++, p[i] + p[i + 1] + p[i + 2] < 72 && dark++);
|
||
if (!clear || dark < clear * 0.9) return img; // real transparency (cut-outs, glass): leave it
|
||
const out = new ImageData(new Uint8ClampedArray(p), d.width, d.height);
|
||
const o = out.data;
|
||
for (let i = 0; i < o.length; i += 4) {
|
||
const a = o[i + 3] / 255;
|
||
for (let k = 0; k < 3; k++) o[i + k] = c[k] * (1 - a) + o[i + k] * a;
|
||
o[i + 3] = 255;
|
||
}
|
||
return out;
|
||
}
|
||
function loading(on, text = "Loading…") {
|
||
$("cos-loading").hidden = !on;
|
||
$("cos-loading").querySelector("span").textContent = text;
|
||
}
|
||
|
||
// =====================================================================
|
||
// source + list
|
||
// =====================================================================
|
||
async function setSource(src) {
|
||
try {
|
||
const d = await call("mdl:describe", src);
|
||
COS.src = { ...src, label: d.label };
|
||
} catch (err) {
|
||
return toast(err.message, true);
|
||
}
|
||
$("cos-src-label").textContent = COS.src.kind === "dir" ? "Extracted folder" : COS.src.kind === "packs" ? "Pack folder" : `Packs next to ${COS.src.name}`;
|
||
$("cos-src").title = `Reading from ${COS.src.label}`;
|
||
kit?.clearTextures();
|
||
await loadList();
|
||
await loadHero();
|
||
}
|
||
async function chooseSource() {
|
||
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||
const options = [
|
||
...profileSourceOption(),
|
||
...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })),
|
||
{ value: "packs", title: "A folder with .iop packs…", sub: "the client folder: map.iop, map2.iop, rs.iop…" },
|
||
{ value: "dir", title: "A folder of extracted files…", sub: "resource/skeleton, resource/mesh, resource/model…" },
|
||
];
|
||
let choice = options[0].value;
|
||
const list = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [el("input", { type: "radio", name: "cossrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])])));
|
||
if (!(await modal("Where is the hero body?", ["The hero skeleton, body meshes and animations are spread over several packs. Pick the folder they are in.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false;
|
||
let src = choice;
|
||
if (choice === "packs" || choice === "dir") {
|
||
const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files");
|
||
if (!folder) return false;
|
||
src = { kind: choice, folder };
|
||
}
|
||
await setSource(src);
|
||
return true;
|
||
}
|
||
$("cos-src").onclick = () => chooseSource();
|
||
|
||
const list = new VTable($("cos-list"), [{ key: 1, label: "Name", width: 200, grow: true, title: true }, { key: 2, label: "Pack", width: 70, cls: "mono dim" }], { multi: false, id: "cos" });
|
||
list.keyOf = (r) => r[0];
|
||
list.sortBy(1);
|
||
async function loadList() {
|
||
if (!COS.src) return;
|
||
loading(true, "Reading the pack index…");
|
||
try {
|
||
const rows = await call("mdl:list", COS.src);
|
||
COS.rows = rows
|
||
.map(([k, path, pack]) => {
|
||
const l = path.toLowerCase();
|
||
const kind = l.endsWith(".msh") ? "msh" : l.endsWith(".ani") ? "ani" : null;
|
||
return kind && [k, stem(path), pack || path.split("/").slice(0, -1).join("/"), kind, path];
|
||
})
|
||
.filter(Boolean);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
COS.rows = [];
|
||
} finally {
|
||
loading(false);
|
||
}
|
||
filterList();
|
||
}
|
||
function filterList() {
|
||
const q = $("cos-search").value.trim().toLowerCase();
|
||
const rows = COS.rows.filter((r) => r[3] === COS.kind && (!q || r[1].toLowerCase().includes(q)));
|
||
list.setRows(rows);
|
||
$("cos-count").textContent = rows.length.toLocaleString();
|
||
}
|
||
let searchTimer = 0;
|
||
$("cos-search").addEventListener("input", () => {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(filterList, 120);
|
||
});
|
||
for (const b of $("cos-kinds").children)
|
||
b.onclick = () => {
|
||
COS.kind = b.dataset.kind;
|
||
for (const x of $("cos-kinds").children) x.classList.toggle("on", x === b);
|
||
filterList();
|
||
};
|
||
list.onOpen = (r) => openRow(r);
|
||
$("cos-list").addEventListener("contextmenu", (e) => {
|
||
const rowEl = e.target.closest(".vt-row");
|
||
if (!rowEl) return;
|
||
e.preventDefault();
|
||
const r = list.view[Number(rowEl.dataset.i)];
|
||
const items = r[3] === "msh" ? [{ label: "Put on the hero", icon: "plus", run: () => openRow(r) }] : [{ label: "Play on the hero", icon: "play", run: () => openRow(r) }];
|
||
items.push("-", { label: "Copy name", icon: "copy", run: () => navigator.clipboard.writeText(r[4].split("/").pop()) });
|
||
showMenu(items, { x: e.clientX, y: e.clientY });
|
||
});
|
||
async function openRow(r) {
|
||
if (!r || !COS.sk) return;
|
||
try {
|
||
const hit = await call("mdl:readKey", COS.src, r[0]);
|
||
if (!hit) throw new Error("Not found in the packs any more.");
|
||
if (r[3] === "msh") await wear(hit, r[1]);
|
||
else await playAni(hit, `${r[1]}.ani`);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
const readNamed = (name, folder, strict = true) => (COS.src ? call("mdl:read", COS.src, name, folder, strict) : null);
|
||
|
||
// =====================================================================
|
||
// the hero
|
||
// =====================================================================
|
||
async function loadHero() {
|
||
if (!COS.src || !ensureView()) return;
|
||
loading(true, "Loading the hero…");
|
||
try {
|
||
const model = await kit.readModel(`animodel/sp2_human_${COS.sex}.txt`);
|
||
if (!model) throw new Error(`model/animodel/sp2_human_${COS.sex}.txt isn't in these packs.`);
|
||
const cfg = model.cfg;
|
||
if (cfg.color?.length >= 3 && String(cfg.color) !== String(COS.skin)) {
|
||
COS.skin = cfg.color.slice(0, 3);
|
||
kit.clearTextures();
|
||
}
|
||
const sh = await readNamed(cfg.skeleton, "skeleton");
|
||
if (!sh) throw new Error(`${cfg.skeleton} isn't in these packs.`);
|
||
const data = LSF.parseSkl(sh.bytes);
|
||
const sk = SkinKit.prepare(data);
|
||
if (data.linked) {
|
||
const lh = await readNamed(data.linked, "skeleton");
|
||
if (!lh || !SkinKit.link(sk, LSF.parseSkl(lh.bytes))) toast(`${cfg.skeleton} links to ${data.linked}, which isn't in these packs; the body may look off.`, true);
|
||
}
|
||
sk.name = cfg.skeleton;
|
||
for (const m of [...COS.base, ...COS.worn]) view.removeMesh(m.id);
|
||
COS.sk = sk;
|
||
COS.P = CK.restPose(sk);
|
||
COS.base = [];
|
||
COS.worn = [];
|
||
const missing = [];
|
||
for (const n of cfg.meshes) {
|
||
const h = await readNamed(`${n}.msh`, "mesh");
|
||
if (!h) {
|
||
missing.push(n);
|
||
continue;
|
||
}
|
||
const m = await kit.loadMesh(h, n);
|
||
m.palette = SkinKit.palette(sk, m.data);
|
||
COS.base.push(m);
|
||
kit.loadTextures(m).then(() => (view.dirty = true));
|
||
}
|
||
const posedBase = COS.base.map((m) => CK.posed(sk, COS.P, m.data));
|
||
COS.surface = CK.surface(posedBase);
|
||
// outfit only: the hero's body and hands go into the costume, its face tells the model's face apart
|
||
COS.heroParts = {};
|
||
COS.base.forEach((m, i) => {
|
||
const k = /hand/i.test(m.name) ? "hand" : /face/i.test(m.name) ? "face" : /hair/i.test(m.name) ? null : /body/i.test(m.name) ? "body" : null;
|
||
if (k && !COS.heroParts[k]) COS.heroParts[k] = { i, m, posed: posedBase[i] };
|
||
});
|
||
COS.faceSurface = COS.heroParts.face ? CK.surface([COS.heroParts.face.posed]) : null;
|
||
// what Fit to the LS body measures: this hero's body, hands and face (not its hair)
|
||
COS.hero = CK.bodyPoints(posedBase.filter((_, i) => !/hair/i.test(COS.base[i].name)));
|
||
COS.anims = cfg.animations;
|
||
renderAnimSelect();
|
||
$("cos-empty").hidden = true;
|
||
if (missing.length) toast(`Not in these packs: ${missing.join(", ")}`, true);
|
||
if (COS.ani) COS.ani.map = SkinKit.mapTracks(sk, COS.ani.data);
|
||
if (COS.file) rebuild();
|
||
frame();
|
||
applyPose();
|
||
renderSide();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
} finally {
|
||
loading(false);
|
||
}
|
||
}
|
||
for (const b of $("cos-sex").children)
|
||
b.onclick = () => {
|
||
if (COS.sex === b.dataset.sex) return;
|
||
COS.sex = b.dataset.sex;
|
||
for (const x of $("cos-sex").children) x.classList.toggle("on", x === b);
|
||
loadHero();
|
||
};
|
||
|
||
async function wear(hit, name) {
|
||
const m = await kit.loadMesh(hit, name);
|
||
m.palette = SkinKit.palette(COS.sk, m.data);
|
||
COS.worn.push(m);
|
||
kit.loadTextures(m).then(() => (view.dirty = true));
|
||
// a costume body / hands / helmet replaces the hero's own one
|
||
for (const s of Object.values(PARTS)) if (s.hides && s.hides.test(name)) for (const b of COS.base) if (s.hides.test(b.name)) b.hidden = true;
|
||
applyPose();
|
||
renderSide();
|
||
}
|
||
|
||
// =====================================================================
|
||
// animation + pose
|
||
// =====================================================================
|
||
function renderAnimSelect() {
|
||
const sel = $("cos-anim");
|
||
const names = [...new Set([...(COS.ani ? [COS.ani.name] : []), ...COS.anims])];
|
||
sel.replaceChildren(el("option", { value: "", text: "— rest pose (T-pose) —" }), ...names.map((n) => el("option", { value: n, text: n, selected: COS.ani?.name === n && !COS.tpose })));
|
||
}
|
||
$("cos-anim").onchange = async () => {
|
||
const n = $("cos-anim").value;
|
||
if (!n) return showTpose();
|
||
const h = await readNamed(n, "animation", false);
|
||
if (!h) return toast(`${n} isn't in these packs.`, true);
|
||
playAni(h, n);
|
||
};
|
||
async function playAni(hit, name) {
|
||
if (!COS.sk) return;
|
||
const data = LSF.parseAni(hit.bytes);
|
||
COS.ani = { data, map: SkinKit.mapTracks(COS.sk, data), name, total: data.total || Math.max(1, ...data.tracks.map((t) => t.times?.[t.times.length - 1] || 0)) };
|
||
COS.time = 0;
|
||
COS.tpose = false;
|
||
renderAnimSelect();
|
||
play();
|
||
}
|
||
function showTpose() {
|
||
stop();
|
||
COS.tpose = true;
|
||
renderAnimSelect();
|
||
applyPose();
|
||
}
|
||
$("cos-tpose").onclick = showTpose;
|
||
|
||
let raf = 0, last = 0;
|
||
function play() {
|
||
if (!COS.ani) return;
|
||
COS.tpose = false;
|
||
COS.playing = true;
|
||
$("cos-play").replaceChildren(icon("pause"));
|
||
last = performance.now();
|
||
cancelAnimationFrame(raf);
|
||
const tick = (now) => {
|
||
if (!COS.playing) return;
|
||
COS.time = (COS.time + (now - last)) % Math.max(1, COS.ani.total);
|
||
last = now;
|
||
if (visible()) applyPose();
|
||
raf = requestAnimationFrame(tick);
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
}
|
||
function stop() {
|
||
COS.playing = false;
|
||
cancelAnimationFrame(raf);
|
||
$("cos-play").replaceChildren(icon("play"));
|
||
}
|
||
$("cos-play").onclick = () => (COS.playing ? stop() : COS.ani ? play() : toast("Pick an animation (the list below the view, or Anims on the left)."));
|
||
$("cos-scrub").oninput = () => {
|
||
if (!COS.ani) return;
|
||
stop();
|
||
COS.tpose = false;
|
||
COS.time = (Number($("cos-scrub").value) / 1000) * COS.ani.total;
|
||
applyPose();
|
||
};
|
||
|
||
const allMeshes = () => [...COS.base, ...COS.worn, ...PART_ORDER.map((p) => COS.views[p]).filter(Boolean)];
|
||
function applyPose() {
|
||
if (!view || !COS.sk) return;
|
||
const ps = COS.tpose || !COS.ani ? COS.P : SkinKit.pose(COS.sk, COS.ani.data, COS.ani.map, COS.time);
|
||
for (const m of allMeshes()) {
|
||
view.setVisible(m.id, !m.hidden);
|
||
if (m.hidden || !m.palette) continue;
|
||
SkinKit.skin(m, m.palette, ps);
|
||
view.updateMesh(m.id, m.skinPos, m.skinNrm);
|
||
}
|
||
// bones
|
||
if (toggled("cos-t-bones")) {
|
||
const l = [];
|
||
COS.sk.parent.forEach((pi, i) => {
|
||
if (pi < 0) return;
|
||
l.push(...ps.wp[pi], 0.96, 0.79, 0.35, 1, ...ps.wp[i], 0.96, 0.79, 0.35, 1);
|
||
});
|
||
view.setOverlay("bones", new Float32Array(l), { depth: false });
|
||
} else view.setOverlay("bones", null);
|
||
view.opts.wire = toggled("cos-t-wire");
|
||
const t = COS.ani && !COS.tpose ? COS.time : 0;
|
||
$("cos-clock").textContent = COS.ani && !COS.tpose ? `${(t / 1000).toFixed(2)} / ${(COS.ani.total / 1000).toFixed(2)} s` : "rest pose";
|
||
if (!$("cos-scrub").matches(":active")) $("cos-scrub").value = COS.ani ? String(Math.round((t / COS.ani.total) * 1000)) : "0";
|
||
view.dirty = true;
|
||
}
|
||
|
||
function frame() {
|
||
view.frame([-60, 0, -30], [60, 170, 30]);
|
||
const g = [];
|
||
const c = [1, 1, 1, 0.16], a = [1, 1, 1, 0.4];
|
||
for (let v = -100; v <= 100; v += 20) {
|
||
const col = v === 0 ? a : c;
|
||
g.push(v, 0, -100, ...col, v, 0, 100, ...col, -100, 0, v, ...col, 100, 0, v, ...col);
|
||
}
|
||
view.setOverlay("grid", new Float32Array(g));
|
||
view.showOverlay("grid", toggled("cos-t-grid"));
|
||
}
|
||
for (const id of ["cos-t-bones", "cos-t-grid", "cos-t-wire"])
|
||
$(id).onclick = () => {
|
||
$(id).classList.toggle("on");
|
||
view?.showOverlay("grid", toggled("cos-t-grid"));
|
||
applyPose();
|
||
};
|
||
$("cos-reset").onclick = () => view && frame();
|
||
|
||
// =====================================================================
|
||
// import
|
||
// =====================================================================
|
||
async function readImage(im) {
|
||
if (!im?.bytes) return null;
|
||
const n = (im.name || "").toLowerCase();
|
||
if (n.endsWith(".dds") || (im.bytes[0] === 0x44 && im.bytes[1] === 0x44 && im.bytes[2] === 0x53)) return TexDecode.dds(im.bytes);
|
||
if (n.endsWith(".tga")) return TexDecode.tga(im.bytes);
|
||
return createImageBitmap(new Blob([im.bytes], { type: im.mime || "image/png" }), { premultiplyAlpha: "none" });
|
||
}
|
||
/**
|
||
* A file a model points to: where it says, next to the model, or in a textures folder near it (Blender
|
||
* often writes broken absolute paths such as C:/11.png). → bytes (throws when not found)
|
||
*/
|
||
async function findFile(uri, dir) {
|
||
const name = uri.split(/[\\/]/).pop();
|
||
const abs = /^([a-z]:|[\\/])/i.test(uri);
|
||
const up = dirOf(dir);
|
||
const tries = [abs ? uri : `${dir}/${uri}`, `${dir}/${name}`, `${dir}/textures/${name}`, `${dir}/texture/${name}`, `${up}/textures/${name}`, `${up}/texture/${name}`, ...(COS.texDir ? [`${COS.texDir}/${name}`] : [])];
|
||
for (const f of tries) {
|
||
const hit = await call("mdl:readFile", f).catch(() => null);
|
||
if (hit) return hit.bytes;
|
||
}
|
||
throw new Error(`${name} not found`);
|
||
}
|
||
async function importFile(path) {
|
||
if (!COS.sk && !(COS.src || (await chooseSource()))) return;
|
||
if (!COS.sk) return toast("The hero body didn't load; pick the game's packs first.", true);
|
||
loading(true, "Reading the 3D model…");
|
||
try {
|
||
const dir = dirOf(path);
|
||
const readRel = (uri) => findFile(uri, dir);
|
||
const bytes = (await call("mdl:readFile", path)).bytes;
|
||
let data;
|
||
if (/\.obj$/i.test(path)) {
|
||
const text = new TextDecoder().decode(bytes);
|
||
const lib = /^mtllib\s+(.+)$/m.exec(text)?.[1]?.trim();
|
||
let mtl = "";
|
||
if (lib) mtl = await readRel(lib).then((b) => new TextDecoder().decode(b)).catch(() => "");
|
||
data = CK.parseObj(text, mtl);
|
||
for (const im of data.images) im.bytes = await readRel(im.name).catch(() => null);
|
||
for (const im of data.images) im.name = im.name.split(/[\\/]/).pop();
|
||
} else if (/\.(glb|gltf)$/i.test(path)) data = await CK.loadGltf(bytes, readRel);
|
||
else if (/\.fbx$/i.test(path)) {
|
||
data = await CK.parseFbx(bytes);
|
||
for (const im of data.images) im.bytes = await readRel(im.name).catch(() => null);
|
||
for (const im of data.images) im.name = im.name.split(/[\\/]/).pop();
|
||
} else throw new Error("Use a .glb, .gltf, .obj or .fbx file (Blender: File → Export → glTF 2.0).");
|
||
await texturesFromJson(data, readRel);
|
||
for (const m of COS.mats) m.tex && view.gl.deleteTexture(m.tex);
|
||
COS.mats = await Promise.all(
|
||
data.materials.map(async (m) => {
|
||
const image = m.image >= 0 ? data.images[m.image] : null;
|
||
const img = await readImage(image).catch(() => null);
|
||
return { name: m.name, color: m.color, image: image?.bytes ? image : null, wanted: image && !img ? image.name : null, img, tex: img ? view.texture(img) : null };
|
||
}),
|
||
);
|
||
COS.file = { path, name: path.split(/[\\/]/).pop(), data, meshNames: data.meshes.map((m) => m.name) };
|
||
const auto = CK.autoScale(COS.sk, COS.P, data);
|
||
COS.file.auto = auto;
|
||
COS.texPix = null;
|
||
// a rig whose bones aren't the hero's names: fit it onto the hero's proportions
|
||
const named = CK.jointMap(COS.sk, data.joints).filter((b) => b >= 0).length;
|
||
COS.opts.fit = data.joints.length > 0 && named < 8;
|
||
Object.assign(COS.opts, { scale: 0, offset: [0, 0, 0], rotY: 0, weights: "auto", name: safeName(stem(path)).replace(/_(body|hand|helmet|cloak)(_\d+)?$/, "").replace(/_[ta]_?pose$/, "") });
|
||
// each mesh: what it hangs on (head → helmet, a cape → cloak) or what the file is called
|
||
const whole = guessPart(COS.file.name);
|
||
let guess = null;
|
||
try {
|
||
guess = data.joints.length ? CK.partsGuess(data, COS.sk, COS.P, { ...COS.opts, fitHero: COS.hero }) : null;
|
||
} catch {}
|
||
COS.opts.parts = Object.fromEntries(data.meshes.map((m) => [m.name, whole !== "body" ? whole : guess?.[m.name] || "body"]));
|
||
rebuild();
|
||
COS.dirty = true;
|
||
toast(`${COS.file.name}: ${PART_ORDER.filter((p) => COS.out[p]).map((p) => `${p} ${COS.out[p].report.vertices.toLocaleString()}`).join(", ") || "nothing"} vertices, scale ${auto.scale.toFixed(2)} (${auto.by}).`);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
console.error(err);
|
||
} finally {
|
||
loading(false);
|
||
}
|
||
}
|
||
/**
|
||
* Game rips (FModel / UE Viewer) keep the textures out of the model: Materials/<material>.json lists
|
||
* them (MainTex, BaseColor, Diffuse…), the pictures are in Textures/. A material without a texture
|
||
* takes its diffuse from there, or a picture named like it (T_<name>_D.png).
|
||
*/
|
||
async function texturesFromJson(data, readRel) {
|
||
const read = async (rel) => (await readRel(rel).catch(() => null)) || null;
|
||
for (const m of data.materials) {
|
||
if (m.image >= 0 && data.images[m.image]?.bytes) continue;
|
||
const tries = [];
|
||
const js = (await read(`Materials/${m.name}.json`)) || (await read(`${m.name}.json`));
|
||
if (js) {
|
||
try {
|
||
const t = JSON.parse(new TextDecoder().decode(js)).Textures || {};
|
||
const keys = Object.keys(t);
|
||
const key = keys.find((k) => /^(MainTex|BaseColor|BaseColorMap|Diffuse|DiffuseMap|Albedo|Base_Color|Color)$/i.test(k)) || keys.find((k) => /(diffuse|basecolor|albedo|maintex)/i.test(k) && !/^PM_/i.test(k)) || keys.find((k) => /_D$/i.test(String(t[k]).split(".")[0]));
|
||
if (key) tries.push(String(t[key]).split("/").pop().split(".")[0]);
|
||
} catch {}
|
||
}
|
||
const base = m.name.replace(/^(MI_|M_|Mat_)/i, "");
|
||
tries.push(`T_${base}_D`, `${base}_D`, base);
|
||
for (const n of tries) {
|
||
let hit = null, name = null;
|
||
for (const ext of ["png", "tga", "dds", "jpg"]) for (const rel of [`Textures/${n}.${ext}`, `${n}.${ext}`]) if (!hit && (hit = await read(rel))) name = `${n}.${ext}`;
|
||
if (hit) {
|
||
data.images.push({ name, bytes: hit, mime: /\.jpe?g$/i.test(name) ? "image/jpeg" : "image/png" });
|
||
m.image = data.images.length - 1;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const safeName = (s) => s.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "costume";
|
||
const guessPart = (n) => (/hand|glove/i.test(n) ? "hand" : /helmet|hat|cap|mask/i.test(n) ? "helmet" : /cloak|cape|wing/i.test(n) ? "cloak" : "body");
|
||
$("cos-import").onclick = async () => {
|
||
if (!COS.sk && !(await chooseSource())) return;
|
||
const file = await call("dialog:openExt", "Import a 3D model as a costume", "3D models", ["glb", "gltf", "fbx", "obj"]);
|
||
if (file) importFile(file);
|
||
};
|
||
// drop a file on the view
|
||
$("cos-canvas").addEventListener("dragover", (e) => e.preventDefault());
|
||
$("cos-canvas").addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
const f = e.dataTransfer.files[0];
|
||
const p = f && bridge.pathOf?.(f);
|
||
if (p) importFile(p);
|
||
});
|
||
|
||
/** The hero meshes the costume's parts replace in the game are hidden (a helmet with a face: the face too). */
|
||
function hideForParts() {
|
||
for (const b of COS.base)
|
||
b.hidden = PART_ORDER.some((p) => COS.out[p] && PARTS[p].hides?.test(b.name)) || (!!COS.out.helmet && COS.opts.hideFace && /face/i.test(b.name));
|
||
}
|
||
|
||
/** Puts a built mesh in the view (reusing old's id). → the view mesh */
|
||
function showBuilt(b, old, name) {
|
||
const d = b.data;
|
||
const id = old?.id || `cos-${Date.now()}-${name}`;
|
||
const groups = b.groups.map((g) => {
|
||
const ls = lsMat(g.material);
|
||
if (ls) return Object.assign({}, ls.group, { start: g.start, count: g.count, tex: ls.group?.texInfo?.tex || null });
|
||
const m = COS.mats[g.material];
|
||
const look = kit.groupLook(null, d);
|
||
return Object.assign(look, { start: g.start, count: g.count, tex: m?.tex || null, color: !m?.tex && m ? [m.color[0], m.color[1], m.color[2], 1] : null });
|
||
});
|
||
view.addMesh(id, { pos: d.pos, normal: d.normal, uv0: d.uv0, indices: Uint32Array.from(d.indices), groups });
|
||
return { id, name, data: d, groups, built: b, palette: SkinKit.palette(COS.sk, d), skinPos: new Float32Array(d.pos.length), skinNrm: new Float32Array(d.normal.length), costume: true, hidden: old?.hidden };
|
||
}
|
||
|
||
/** The hero's body / hand mesh at its rest pose for build's base (outfit only): its groups keep the game's material ("ls:<base>:<sub>"). */
|
||
function heroBase(k, hand = false) {
|
||
const h = COS.heroParts?.[k];
|
||
if (!h) return null;
|
||
const subs = h.posed.subs?.length ? h.posed.subs : [{ indexStart: 0, faceCount: h.posed.idx.length / 3 }];
|
||
return { ...h.posed, subs, hand, mats: subs.map((_, s) => `ls:${h.i}:${s}`) };
|
||
}
|
||
/** A built group's game material: "ls:<base>:<sub>" → { mesh, group, sub } */
|
||
const lsMat = (key) => {
|
||
if (typeof key !== "string") return null;
|
||
const [, i, sub] = key.split(":").map(Number);
|
||
const mesh = COS.base[i];
|
||
return mesh ? { mesh, group: mesh.groups[sub], sub } : null;
|
||
};
|
||
/** The model's textures' colours (≤ 512 px), for Outfit only's skin tone: (material, u, v) → [r, g, b, a] | null */
|
||
function texel() {
|
||
if (!COS.texPix) COS.texPix = COS.mats.map((m) => (m?.img ? pixels(m.img, 512) : null));
|
||
return (mi, u, v) => {
|
||
const t = COS.texPix[mi];
|
||
if (!t) return null;
|
||
u -= Math.floor(u);
|
||
v -= Math.floor(v);
|
||
const i = (Math.min(t.h - 1, Math.floor(v * t.h)) * t.w + Math.min(t.w - 1, Math.floor(u * t.w))) * 4;
|
||
return [t.rgba[i], t.rgba[i + 1], t.rgba[i + 2], t.rgba[i + 3]];
|
||
};
|
||
}
|
||
/** The top-level "material { … }" blocks of a material script, in order. */
|
||
function materialBlocks(text) {
|
||
const out = [];
|
||
let depth = 0, start = -1;
|
||
const re = /\bmaterial\b|[{}]/g;
|
||
let m;
|
||
while ((m = re.exec(text))) {
|
||
if (m[0] === "material" && depth === 0 && start < 0) start = m.index;
|
||
else if (m[0] === "{") depth++;
|
||
else if (m[0] === "}" && --depth === 0 && start >= 0) out.push(text.slice(start, m.index + 1)), (start = -1);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Builds every part from the file + options and shows them: one placement / fit for all (measured on
|
||
* every mesh that isn't left out), then a mesh per part; the body's hands split off unless the file
|
||
* has a hands part of its own.
|
||
*/
|
||
function rebuild() {
|
||
if (!COS.file || !COS.sk) return;
|
||
const o = COS.opts;
|
||
const parts = Object.entries(o.parts).filter(([, p]) => p !== "skip");
|
||
const out = {};
|
||
let rig = null;
|
||
try {
|
||
rig = CK.prepare(COS.file.data, COS.sk, COS.P, { ...o, fitHero: COS.hero, measure: new Set(parts.map(([n]) => n)) });
|
||
} catch (err) {
|
||
toast(`Fit: ${err.message}`, true);
|
||
rig = CK.prepare(COS.file.data, COS.sk, COS.P, { ...o, fit: false });
|
||
}
|
||
const handsPart = parts.some(([, p]) => p === "hand");
|
||
const outfit = !!(o.outfit && o.fit && rig?.T);
|
||
for (const part of PART_ORDER) {
|
||
const include = new Set(parts.filter(([, p]) => p === part).map(([n]) => n));
|
||
if (!include.size) continue;
|
||
try {
|
||
const splitHands = part === "body" && o.splitHands && !handsPart;
|
||
const base = !outfit ? null : part === "body" ? [heroBase("body"), splitHands ? heroBase("hand", true) : null].filter(Boolean) : part === "hand" ? [heroBase("hand")].filter(Boolean) : null;
|
||
const r = CK.build(COS.file.data, COS.sk, COS.P, COS.surface, { ...o, rig, include, splitHands, outfit, texel: outfit ? texel() : null, face: COS.faceSurface, base });
|
||
if (!r) {
|
||
toast(`${PARTS[part].label}: only skin / face — nothing of it is left (Outfit only).`);
|
||
continue;
|
||
}
|
||
out[part] = r;
|
||
if (r.hand) out.hand = r.hand;
|
||
} catch (err) {
|
||
toast(`${PARTS[part].label}: ${err.message}`, true);
|
||
}
|
||
}
|
||
COS.out = out;
|
||
for (const p of PART_ORDER) {
|
||
if (out[p]) COS.views[p] = showBuilt(out[p], COS.views[p], outName(p));
|
||
else if (COS.views[p]) view.removeMesh(COS.views[p].id), delete COS.views[p];
|
||
}
|
||
hideForParts();
|
||
$("cos-title").textContent = COS.file.name;
|
||
$("cos-dirty").hidden = !COS.dirty;
|
||
$("cos-save").disabled = !anyOut();
|
||
applyPose();
|
||
renderSide();
|
||
}
|
||
let rebuildTimer = 0;
|
||
const rebuildSoon = () => {
|
||
clearTimeout(rebuildTimer);
|
||
rebuildTimer = setTimeout(rebuild, 150);
|
||
};
|
||
|
||
// =====================================================================
|
||
// side cards
|
||
// =====================================================================
|
||
const field = (label, input) => [el("label", { text: label }), input];
|
||
const num = (value, onchange, step = "any") => el("input", { type: "number", step, value: String(value), onchange: (e) => onchange(Number(e.target.value)) });
|
||
function renderSide() {
|
||
renderChar();
|
||
renderOpts();
|
||
renderMats();
|
||
renderReport();
|
||
renderReg();
|
||
}
|
||
|
||
function renderChar() {
|
||
const box = $("cos-char");
|
||
$("cos-char-name").textContent = COS.sk ? `sp2_human_${COS.sex} · ${COS.sk.name}` : "";
|
||
if (!COS.sk) return box.replaceChildren(el("div", { class: "ans-note", text: "Pick the game's packs (top left)." }));
|
||
const row = (m, removable) =>
|
||
el("div", { class: "ans-mesh" }, [
|
||
el("input", { type: "checkbox", checked: !m.hidden, title: "Show / hide", onchange: (e) => ((m.hidden = !e.target.checked), applyPose()) }),
|
||
el("span", { class: `mono grow${m.costume ? " ok" : ""}`, text: m.costume ? `${m.name} (import)` : m.name, title: m.name }),
|
||
removable ? el("button", { class: "iconbtn only danger", title: "Take it off", onclick: () => takeOff(m) }, [icon("trash")]) : null,
|
||
]);
|
||
box.replaceChildren(...COS.base.map((m) => row(m, false)), ...COS.worn.map((m) => row(m, true)), ...PART_ORDER.filter((p) => COS.views[p]).map((p) => row(COS.views[p], false)), el("div", { class: "ans-note", text: "Unticked meshes are hidden (in the game the costume's body replaces human_body, its hands default_hand, a helmet the hair)." }));
|
||
}
|
||
function takeOff(m) {
|
||
view.removeMesh(m.id);
|
||
COS.worn.splice(COS.worn.indexOf(m), 1);
|
||
applyPose();
|
||
renderSide();
|
||
}
|
||
|
||
function renderOpts() {
|
||
const box = $("cos-opts");
|
||
const o = COS.opts;
|
||
$("cos-file-name").textContent = COS.file?.name || "";
|
||
if (!COS.file) return box.replaceChildren(el("div", { class: "ans-note", text: "Import 3D… a .glb / .gltf / .fbx / .obj (or drop it on the view)." }), el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", onclick: () => $("cos-import").onclick() }, [icon("import"), "Import 3D…"])]));
|
||
const set = (k, v, rebuildNow = true) => {
|
||
o[k] = v;
|
||
COS.dirty = true;
|
||
if (rebuildNow) rebuildSoon();
|
||
};
|
||
const hasJoints = COS.file.data.joints.length > 0;
|
||
const weights = el("select", { onchange: () => (set("weights", weights.value), renderOpts()) }, [
|
||
["auto", hasJoints ? "Auto (the file's bones)" : "Auto (from the hero body)"],
|
||
["file", "The file's bones (same names as the hero's)"],
|
||
["body", "From the hero body (closest point)"],
|
||
["bone", "All on one bone"],
|
||
].map(([v, t]) => el("option", { value: v, text: t, selected: v === o.weights, disabled: v === "file" && !hasJoints })));
|
||
const bone = el("select", { onchange: () => set("bone", Number(bone.value)) }, COS.sk.data.bones.map((b, i) => el("option", { value: i, text: b.name.text, selected: i === o.bone })));
|
||
const auto = COS.file.auto;
|
||
const scale = num(o.scale || +auto.scale.toFixed(4), (v) => set("scale", v > 0 ? v : 0));
|
||
const off = o.offset.map((v, i) => num(v, (x) => set("offset", Object.assign(o.offset.slice(), { [i]: x || 0 }))));
|
||
const rot = el("select", { onchange: () => set("rotY", Number(rot.value)) }, [0, 90, 180, -90].map((v) => el("option", { value: v, text: `${v}°`, selected: v === o.rotY })));
|
||
// what each mesh of the file becomes
|
||
const meshes = el(
|
||
"div",
|
||
{ class: "cos-parts" },
|
||
COS.file.meshNames.map((n) => {
|
||
const verts = COS.file.data.meshes.find((m) => m.name === n)?.prims.reduce((t, p) => t + p.pos.length / 3, 0) || 0;
|
||
const sel = el("select", { onchange: () => set("parts", { ...o.parts, [n]: sel.value }) }, Object.entries(PARTS).map(([k, p]) => el("option", { value: k, text: p.label, selected: (o.parts[n] || "body") === k })));
|
||
return el("div", { class: "ans-mesh" }, [el("span", { class: "mono grow", text: n, title: `${n}: ${verts.toLocaleString()} vertices` }), sel]);
|
||
}),
|
||
);
|
||
const name = el("input", { type: "text", value: o.name, spellcheck: "false", onchange: () => {
|
||
const v = name.value.replace(/\.msh$/i, "").trim().toLowerCase();
|
||
if (!/^[a-z0-9_\-]+$/.test(v)) return toast("Use a-z, 0-9, _ and - (the client's file names).", true);
|
||
o.name = v;
|
||
for (const p of PART_ORDER) if (COS.views[p]) COS.views[p].name = outName(p);
|
||
renderSide();
|
||
renderChar();
|
||
} });
|
||
box.replaceChildren(
|
||
el("div", { class: "ans-grid" }, [
|
||
...field("Weights", weights),
|
||
...(o.weights === "bone" ? field("Bone", bone) : []),
|
||
...field("Scale", el("div", { class: "ans-vec" }, [scale, el("button", { class: "iconbtn only", title: `Auto: ${auto.scale.toFixed(3)} (${auto.by})`, onclick: () => (set("scale", 0), renderOpts()) }, [icon("refresh")])])),
|
||
...field("Move x y z", el("div", { class: "ans-vec" }, off)),
|
||
...field("Turn", rot),
|
||
...field("Max bones", num(o.maxBones, (v) => set("maxBones", Math.max(1, Math.min(CK.MAX_BONES, Math.round(v) || CK.MAX_BONES))), "1")),
|
||
...field("Save as", name),
|
||
]),
|
||
el("div", { class: "ans-note", text: "Each mesh of the file is a part of the costume (saved and registered on its own):" }),
|
||
meshes,
|
||
el("label", { class: "ans-check", title: COS.file.data.joints.length ? "Finds the rig's hips, spine, head, arms, hands, legs and feet by their shape (any bone names) and reshapes the model to this hero's proportions (Man / Woman): limbs as long and thick as the hero's, the head as big, a cape and long hair hanging the body's length. The weights come from the rig, a cape goes onto the hero's Mantle bones." : "Needs the model's armature: export a .glb / .gltf with it (an .obj has no bones)" }, [el("input", { type: "checkbox", checked: !!o.fit, disabled: !COS.file.data.joints.length, onchange: (e) => set("fit", e.target.checked) }), "Fit to the LS body (" + (COS.sex === "woman" ? "woman" : "man") + ")"]),
|
||
o.fit ? el("label", { class: "ans-check", title: "On: the torso and hips take the hero's width but keep their own depth front to back (a bust, a bottom, a coat stay full). Off: as flat as the hero's body." }, [el("input", { type: "checkbox", checked: o.keepVolume !== false, onchange: (e) => set("keepVolume", e.target.checked) }), "Keep the body's volume (chest, hips)"]) : null,
|
||
o.fit ? el("div", { class: "ans-grid" }, [
|
||
...field("Head size %", el("div", { class: "ans-vec", title: "100 = as tall as the hero's head (chin to crown); the chin stays on the hero's chin" }, [num(Math.round((o.headScale || 1) * 100), (v) => set("headScale", Math.max(0.3, Math.min(3, (v || 100) / 100))), "5")])),
|
||
...field("LS build %", el("div", { class: "ans-vec", title: "How thick arms, legs and torso are: 0 = the model's own build (kept near its shape), 100 = as thick as the hero's body everywhere, like the game's own costumes" }, [num(Math.round((o.lsBuild || 0) * 100), (v) => set("lsBuild", Math.max(0, Math.min(1, (v || 0) / 100))), "10")])),
|
||
]) : null,
|
||
o.fit ? el("label", { class: "ans-check", title: "Takes only the clothes (and hair) of the model: its skin and face are cut away (the skin tone is read off its face's texture) and the costume's body mesh carries the hero's own body underneath — the Man / Woman button picks which. The hero's face shows; the hero's hands stay unless the model wears gloves." }, [el("input", { type: "checkbox", checked: !!o.outfit, onchange: (e) => {
|
||
o.outfit = e.target.checked;
|
||
if (o.outfit) o.hideFace = false; // the hero's own face shows under the hair
|
||
set("outfit", o.outfit);
|
||
renderOpts();
|
||
} }), `Outfit only — on the LS ${COS.sex === "woman" ? "woman's" : "man's"} body`]) : null,
|
||
o.fit && o.outfit ? el("div", { class: "ans-grid" }, [
|
||
...field("Skin match %", el("div", { class: "ans-vec", title: "How close to the face's skin tone a texture colour must be to be cut away: lower if clothes in a skin-like colour (beige, pink) go missing, higher if bits of skin stay" }, [num(Math.round((o.skinTol ?? 1) * 100), (v) => set("skinTol", Math.max(0, Math.min(2, (v ?? 100) / 100))), "10")])),
|
||
]) : null,
|
||
COS.out.helmet ? el("label", { class: "ans-check", title: "The helmet brings its own face: the hero's face is hidden (invisible_face = 1 when registered)" }, [el("input", { type: "checkbox", checked: o.hideFace, onchange: (e) => (set("hideFace", e.target.checked, false), hideForParts(), applyPose(), renderReg()) }), "Helmet hides the hero's face"]) : null,
|
||
Object.values(o.parts).includes("body") && !Object.values(o.parts).includes("hand") ? el("label", { class: "ans-check", title: "Faces that move with a hand or its fingers become a second mesh (hand_mesh, replaces default_hand): a mesh can use 24 bones, the body ~19 and the fingers 20. Only when the model has fingers." }, [el("input", { type: "checkbox", checked: o.splitHands, onchange: (e) => set("splitHands", e.target.checked) }), `Split the hands → ${outName("hand")}`]) : null,
|
||
el("label", { class: "ans-check", title: "Women look for <name>_hw first; without it they wear the men's mesh" }, [el("input", { type: "checkbox", checked: o.hw, onchange: (e) => set("hw", e.target.checked, false) }), "Also save as _hw (women)"]),
|
||
el("div", { class: "ans-note", text: `Units: LS ≈ cm, the hero is ~140 tall; Blender metres × 100. Auto scale: ${auto.by}.` }),
|
||
);
|
||
}
|
||
|
||
function renderMats() {
|
||
const box = $("cos-mats");
|
||
if (!anyOut()) return box.replaceChildren(el("div", { class: "ans-note", text: "—" }));
|
||
const o = COS.opts;
|
||
const used = usedMats();
|
||
const rows = used.map((mi, k) => {
|
||
const m = COS.mats[mi];
|
||
const texName = texNameOf(k);
|
||
return el("div", { class: "ans-mesh" }, [
|
||
el("span", { class: "mono grow", text: `${m?.name || "no material"} → ${texName}`, title: m?.image ? `texture: ${m.image.name} ${m.img ? `${m.img.width}×${m.img.height}` : "(can't read)"}` : "no texture: a plain colour" }),
|
||
el("span", { class: m?.img ? "muted" : "warn", text: m?.img ? `${m.img.width}×${m.img.height}` : m?.wanted ? `${m.wanted}?` : "colour", title: m?.wanted ? `${m.wanted} isn't next to the model: Textures folder… (or the image button)` : "" }),
|
||
el("button", { class: "iconbtn only", title: "Use another texture (.png / .jpg / .dds / .tga)", onclick: () => pickTexture(mi) }, [icon("image")]),
|
||
]);
|
||
});
|
||
const max = el("select", { onchange: () => (o.texMax = Number(max.value)) }, [256, 512, 1024, 2048].map((v) => el("option", { value: v, text: `${v}`, selected: v === o.texMax })));
|
||
const lost = used.filter((mi) => COS.mats[mi]?.wanted);
|
||
box.replaceChildren(
|
||
...rows,
|
||
lost.length ? el("div", { class: "ans-note warn", text: `${lost.length} texture(s) not found: ${[...new Set(lost.map((mi) => COS.mats[mi].wanted))].join(", ")}.` }) : null,
|
||
lost.length ? el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", onclick: () => findTextures().catch((err) => toast(err.message, true)) }, [icon("folder"), "Textures folder…"])]) : null,
|
||
el("div", { class: "ans-grid" }, [...field("Texture max", max)]),
|
||
el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: o.outline, onchange: (e) => (o.outline = e.target.checked) }), "Toon outline (like the game's costumes)"]),
|
||
el("div", { class: "ans-note", text: "Textures are saved as 32-bit .dds (power-of-two sizes); a .dds you pick is kept as it is." }),
|
||
);
|
||
}
|
||
/** Looks for the missing textures (by file name) in a folder the user picks. */
|
||
async function findTextures() {
|
||
const folder = await call("dialog:folder", "Folder with the model's textures");
|
||
if (!folder) return;
|
||
COS.texDir = folder.replace(/\\/g, "/");
|
||
try {
|
||
localStorage.setItem("cos:texDir", COS.texDir);
|
||
} catch {}
|
||
let found = 0;
|
||
for (const m of COS.mats) {
|
||
if (!m?.wanted) continue;
|
||
const bytes = await call("mdl:readFile", `${COS.texDir}/${m.wanted}`).then((h) => h.bytes).catch(() => null);
|
||
if (!bytes) continue;
|
||
const image = { name: m.wanted, bytes, mime: /\.jpe?g$/i.test(m.wanted) ? "image/jpeg" : "image/png" };
|
||
const img = await readImage(image).catch(() => null);
|
||
if (!img) continue;
|
||
Object.assign(m, { image, img, tex: view.texture(img), wanted: null });
|
||
COS.texPix = null;
|
||
found++;
|
||
}
|
||
toast(found ? `Found ${found} texture(s).` : "None of the textures are in that folder.", !found);
|
||
if (found) (COS.dirty = true), rebuild();
|
||
}
|
||
const texNameOf = (k) => `${COS.opts.name}${k ? `_${k + 1}` : ""}.dds`;
|
||
async function pickTexture(mi) {
|
||
const file = await call("dialog:openExt", "Texture for this material", "Images", ["png", "jpg", "jpeg", "dds", "tga", "bmp"]);
|
||
if (!file) return;
|
||
try {
|
||
const bytes = (await call("mdl:readFile", file)).bytes;
|
||
const name = file.split(/[\\/]/).pop();
|
||
const image = { name, bytes, mime: /\.jpe?g$/i.test(name) ? "image/jpeg" : "image/png" };
|
||
const img = await readImage(image);
|
||
const m = COS.mats[mi] || (COS.mats[mi] = { name: "material", color: [1, 1, 1, 1] });
|
||
if (m.tex) view.gl.deleteTexture(m.tex);
|
||
Object.assign(m, { image, img, tex: view.texture(img) });
|
||
COS.texPix = null;
|
||
COS.dirty = true;
|
||
rebuild();
|
||
} catch (err) {
|
||
toast(`Can't read that image: ${err.message}`, true);
|
||
}
|
||
}
|
||
|
||
function renderReport() {
|
||
const box = $("cos-report");
|
||
const first = anyOut();
|
||
if (!first) return box.replaceChildren(el("div", { class: "ans-note", text: "—" }));
|
||
const rows = [];
|
||
const warn = [];
|
||
for (const p of PART_ORDER) {
|
||
const r = COS.out[p]?.report;
|
||
if (!r) continue;
|
||
rows.push(...field(PARTS[p].label, el("span", { class: "mono", text: `${outName(p)} · ${r.vertices.toLocaleString()} v · ${r.faces.toLocaleString()} f · ${r.bones}/${CK.MAX_BONES} bones`, title: r.boneNames.join("\n") })));
|
||
if (r.unweighted) warn.push(`${PARTS[p].label}: ${r.unweighted} vertices have no bone (they stay in place).`);
|
||
if (r.folded.length) warn.push(`${PARTS[p].label}: more than ${COS.opts.maxBones} bones, ${r.folded.length} merged into their parents (${r.folded.slice(0, 3).join(", ")}${r.folded.length > 3 ? "…" : ""}).`);
|
||
}
|
||
const r = first.report;
|
||
const fi = r.fit;
|
||
if (fi) {
|
||
const g = (k) => fi.info[k] || {};
|
||
warn.push(`Fitted (rig: hips ${fi.joints.pelvis}, chest ${fi.joints.chest}, head ${fi.joints.head}, hands ${fi.joints.leftHand} / ${fi.joints.rightHand}${fi.joints.capes ? `, ${fi.joints.capes} cape chain(s)` : ""}): head ×${g("Bip01 Head").scale}, thighs ×${g("Bip01 L Thigh").length}, arms ×${g("Bip01 L UpperArm").length}, feet ×${g("Bip01 L Foot").length}, torso thickness ×${g("Bip01 Spine").thickness}.`);
|
||
}
|
||
const of = PART_ORDER.map((p) => COS.out[p]?.report.outfit).filter(Boolean);
|
||
if (of.length) {
|
||
const t = of[0].tone;
|
||
const hb = COS.out.body?.report.heroBody;
|
||
warn.push(`Outfit only: skin tone ${t ? `rgb(${t.join(", ")})` : "not found (no face texture) — only the face is cut"}; left out ${of.reduce((n, x) => n + x.skin, 0).toLocaleString()} skin and ${of.reduce((n, x) => n + x.face, 0).toLocaleString()} face faces.${hb ? ` The ${COS.sex}'s body is inside the body mesh: ${hb.kept.toLocaleString()} of its faces, ${hb.hidden.toLocaleString()} hidden under the clothes left out.` : ""}`);
|
||
}
|
||
if (COS.out.body?.handNote && COS.opts.splitHands) warn.push(`Split the hands: ${COS.out.body.handNote}.`);
|
||
if (r.mode === "body" && COS.out.body) warn.push("Long skirts / coats: weights from the body follow the closest leg; rig them in Blender for a better swing.");
|
||
box.replaceChildren(
|
||
el("div", { class: "ans-grid" }, [
|
||
...rows,
|
||
...field("Weights", el("span", { text: r.mode === "file" ? `file's bones${r.fromBody ? ` (+${r.fromBody} from the body)` : ""}` : r.mode === "body" ? "from the hero body" : "one bone" })),
|
||
...field("Scale", el("span", { class: "mono", text: r.scale.toFixed(3) })),
|
||
]),
|
||
...warn.map((w) => el("div", { class: "ans-note warn", text: w })),
|
||
);
|
||
}
|
||
|
||
// =====================================================================
|
||
// register in game: a costume entry (costume_*.ini + its name) and the server's code list
|
||
// =====================================================================
|
||
/** The costume INIs the built parts go into: armor (body / hands), helmet, cloak. */
|
||
const kinds = () => [...new Set(PART_ORDER.filter((p) => COS.out[p]).map((p) => PARTS[p].ini))];
|
||
const GRADES = [[0, "Normal"], [2, "Premium"], [3, "Rare"], [4, "Unique"]];
|
||
const REG = { kind: null, info: {}, loading: null, form: null, last: null, serverFile: "" };
|
||
try {
|
||
REG.serverFile = localStorage.getItem("cos:serverFile") || "";
|
||
} catch {}
|
||
async function regInfo(kind, reload = false) {
|
||
if (!reload && REG.info[kind]) return REG.info[kind];
|
||
REG.loading = kind;
|
||
renderReg();
|
||
try {
|
||
REG.info[kind] = await call("cos:info", COS.src, kind);
|
||
} finally {
|
||
REG.loading = null;
|
||
}
|
||
return REG.info[kind];
|
||
}
|
||
/** A new form for the current costume (kind from the slot, the next free code, the meshes it saves). */
|
||
function newForm(kind, info) {
|
||
const o = COS.opts;
|
||
const last = info.list[info.list.length - 1];
|
||
const meshes = {};
|
||
for (const p of PART_ORDER) if (PARTS[p].ini === kind) meshes[PARTS[p].key] = COS.out[p] ? outName(p) : "";
|
||
return { kind, code: info.next, name: COS.file ? stem(COS.file.name) : o.name, grade: 0, template: last?.section || "", icon: last?.icon || "", meshes, addServer: !!REG.serverFile };
|
||
}
|
||
function renderReg() {
|
||
const box = $("cos-reg");
|
||
if (!COS.src) return box.replaceChildren(el("div", { class: "ans-note", text: "—" }));
|
||
const ks = kinds();
|
||
const kind = REG.kind && (ks.includes(REG.kind) || !ks.length) ? REG.kind : ks[0] || "armor";
|
||
$("cos-reg-file").textContent = `costume_${kind}.ini`;
|
||
const info = REG.info[kind];
|
||
if (!info) {
|
||
return box.replaceChildren(
|
||
el("div", { class: "ans-note", text: "Adds the costume to the game: an entry in config/costume/costume_*.ini (code, name, meshes), its name in the string tables, and its code in the server's sp2_costumemanager.ini." }),
|
||
el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", disabled: !!REG.loading, onclick: () => regInfo(kind).then(renderReg).catch((err) => toast(err.message, true)) }, [icon("refresh"), REG.loading ? "Reading the costume list…" : "Read the costume list"])]),
|
||
);
|
||
}
|
||
if (!REG.form || REG.form.kind !== kind) REG.form = newForm(kind, info);
|
||
const f = REG.form;
|
||
// the costume was renamed / moved to another slot: its mesh names follow
|
||
const at = `${COS.opts.name}|${PART_ORDER.filter((p) => COS.out[p]).join(",")}`;
|
||
if (f.at && f.at !== at) Object.assign(f.meshes, newForm(kind, info).meshes);
|
||
f.at = at;
|
||
const kindSel = el("select", { onchange: () => ((REG.kind = kindSel.value), (REG.form = null), regInfo(REG.kind).then(renderReg).catch((err) => toast(err.message, true))) }, ["armor", "helmet", "cloak"].map((k) => el("option", { value: k, text: k, selected: k === kind })));
|
||
const taken = new Set(info.list.map((c) => c.code));
|
||
const code = el("input", { type: "number", step: "1", value: String(f.code), onchange: () => {
|
||
f.code = Number(code.value);
|
||
code.classList.toggle("bad", taken.has(f.code));
|
||
if (taken.has(f.code)) toast(`${f.code} is already a costume (${info.list.find((c) => c.code === f.code).name}).`, true);
|
||
} });
|
||
const name = el("input", { type: "text", value: f.name, placeholder: "shown in the game (any language)", oninput: () => (f.name = name.value) });
|
||
const grade = el("select", { onchange: () => (f.grade = Number(grade.value)) }, GRADES.map(([v, t]) => el("option", { value: v, text: t, selected: v === f.grade })));
|
||
const recent = info.list.slice(-40).reverse();
|
||
const tpl = el("select", { title: "The other keys (glow colours, armour sound…) and the icon are copied from this costume", onchange: () => {
|
||
f.template = tpl.value;
|
||
const t = info.list.find((c) => c.section === f.template);
|
||
if (t?.icon) (f.icon = t.icon), (iconIn.value = t.icon);
|
||
} }, recent.map((c) => el("option", { value: c.section, text: `${c.code} ${c.name}`, selected: c.section === f.template })));
|
||
const iconIn = el("input", { type: "text", value: f.icon, spellcheck: "false", placeholder: "imageset#icon (empty: the code's default)", onchange: () => (f.icon = iconIn.value.trim()) });
|
||
const meshIns = Object.keys(f.meshes).map((k) => field(k, el("input", { type: "text", value: f.meshes[k], spellcheck: "false", placeholder: "(none)", onchange: (e) => (f.meshes[k] = e.target.value.trim().replace(/\.msh$/i, "")) })));
|
||
const server = el("label", { class: "ans-check", title: REG.serverFile || "No file chosen" }, [
|
||
el("input", { type: "checkbox", checked: f.addServer && !!REG.serverFile, disabled: !REG.serverFile, onchange: (e) => (f.addServer = e.target.checked) }),
|
||
REG.serverFile ? `Add the code to ${REG.serverFile.split(/[\\/]/).slice(-2).join("\\")}` : "Server list: not chosen",
|
||
]);
|
||
const done = REG.last
|
||
? el("div", { class: "ans-note" }, [
|
||
`Registered [${REG.last.section}] = ${REG.last.code}. Give it to a player: Monitor → Gift items → `,
|
||
el("b", { class: "mono", text: `12.${REG.last.code}.0` }),
|
||
" (0 = for good).",
|
||
REG.last.server ? ` Server list: ${REG.last.server.added ? `added as [${REG.last.server.section}]` : `already [${REG.last.server.section}]`} — restart / reload the game server.` : " Add it to the server's sp2_costumemanager.ini too, else the server refuses it.",
|
||
])
|
||
: null;
|
||
box.replaceChildren(
|
||
el("div", { class: "ans-grid" }, [
|
||
...field("Kind", kindSel),
|
||
...field("Code", code),
|
||
...field("Name", name),
|
||
...field("Grade", grade),
|
||
...field("Copy from", tpl),
|
||
...field("Icon", iconIn),
|
||
...meshIns.flat(),
|
||
]),
|
||
server,
|
||
el("div", { class: "ans-row" }, [
|
||
el("button", { class: "iconbtn", title: "The server's config/sp2_costumemanager.ini (a copy you upload, or the server folder)", onclick: chooseServerFile }, [icon("folder"), "Server list…"]),
|
||
el("button", { class: "btn primary", onclick: () => register().catch((err) => toast(err.message, true)) }, [icon("plus"), "Register"]),
|
||
ks.length > 1 ? el("button", { class: "iconbtn", title: `One entry per part: ${ks.join(", ")} — the same name, each kind's next free code`, onclick: () => registerAll().catch((err) => toast(err.message, true)) }, [icon("plus"), `Register all (${ks.length})`]) : null,
|
||
]),
|
||
done,
|
||
);
|
||
}
|
||
async function chooseServerFile() {
|
||
const file = await call("dialog:openExt", "The game server's sp2_costumemanager.ini", "INI", ["ini"]);
|
||
if (!file) return;
|
||
REG.serverFile = file;
|
||
try {
|
||
localStorage.setItem("cos:serverFile", file);
|
||
} catch {}
|
||
if (REG.form) REG.form.addServer = true;
|
||
renderReg();
|
||
}
|
||
/** What cos:register gets; a helmet takes the hero's hair off (and the face when it brings its own). */
|
||
const entryOf = (f) => ({
|
||
kind: f.kind,
|
||
code: f.code,
|
||
name: f.name.trim(),
|
||
grade: f.grade,
|
||
icon: f.icon,
|
||
meshes: f.meshes,
|
||
template: f.template,
|
||
serverFile: f.addServer ? REG.serverFile : null,
|
||
extra: f.kind === "helmet" ? { hair_change_hm: "1", hair_change_hw: "1", invisible_face: COS.opts.hideFace ? "1" : "0" } : {},
|
||
});
|
||
/** Every part at once: one entry per costume INI, the form's name / grade / icon, each kind's next code. */
|
||
async function registerAll() {
|
||
const f = REG.form;
|
||
if (!f?.name.trim()) return toast("Give the costume a name.", true);
|
||
const list = [];
|
||
for (const k of kinds()) {
|
||
const info = await regInfo(k, true);
|
||
const nf = { ...newForm(k, info), name: f.name, grade: f.grade, icon: f.icon, addServer: f.addServer };
|
||
list.push(nf);
|
||
}
|
||
const missing = [];
|
||
for (const nf of list) for (const m of Object.values(nf.meshes).filter(Boolean)) if (!(await readNamed(`${m}.msh`, "mesh"))) missing.push(m);
|
||
const body = el("div", {}, [
|
||
el("p", { text: `"${f.name}" as ${list.length} costumes:` }),
|
||
el("div", { class: "mono ans-note" }, list.map((nf) => el("div", { text: `costume_${nf.kind}.ini ${nf.code} ${Object.entries(nf.meshes).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(" ")}` }))),
|
||
missing.length ? el("p", { class: "warn", text: `Not in the packs yet: ${missing.join(", ")} — Save into the packs first.` }) : null,
|
||
]);
|
||
if (!(await modal("Register every part", body, [{ label: "Cancel", value: false }, { label: "Register all", kind: "primary", value: true }]))) return;
|
||
loading(true, "Registering…");
|
||
try {
|
||
const done = [];
|
||
for (const nf of list) {
|
||
const r = await call("cos:register", COS.src, entryOf(nf));
|
||
for (const w of r.warnings) toast(w, true);
|
||
done.push(r);
|
||
await regInfo(nf.kind, true);
|
||
}
|
||
REG.form = null;
|
||
REG.last = done[done.length - 1];
|
||
REG.lastAll = done;
|
||
renderReg();
|
||
toast(`Registered ${done.map((r) => r.code).join(", ")}. Gifts: ${done.map((r) => `12.${r.code}.0`).join(" ")}`);
|
||
} finally {
|
||
loading(false);
|
||
}
|
||
}
|
||
async function register() {
|
||
const f = REG.form;
|
||
const info = REG.info[f.kind];
|
||
if (!f.name.trim()) return toast("Give the costume a name.", true);
|
||
if (info.list.some((c) => c.code === f.code)) return toast(`${f.code} is already a costume.`, true);
|
||
const meshes = Object.entries(f.meshes).filter(([, v]) => v);
|
||
if (!meshes.length) return toast("Fill in the mesh it wears (e.g. body_mesh).", true);
|
||
// the meshes should be in the packs already (Save first)
|
||
const missing = [];
|
||
for (const [, m] of meshes) if (!(await readNamed(`${m}.msh`, "mesh"))) missing.push(m);
|
||
const body = el("div", {}, [
|
||
el("p", { text: `[costume${info.count + 1}] in config/costume/costume_${f.kind}.ini:` }),
|
||
el("div", { class: "mono ans-note" }, [`costum_code = ${f.code}`, `name = STR(1) → "${f.name}" (${info.texts.map((t) => `${t.file} ${t.encoding}`).join(", ")})`, ...meshes.map(([k, v]) => `${k} = ${v}`), f.icon ? `icon_name = ${f.icon}` : ""].filter(Boolean).map((t) => el("div", { text: t }))),
|
||
missing.length ? el("p", { class: "warn", text: `Not in the packs yet: ${missing.join(", ")}.msh — Save the costume into the packs first, else the game shows nothing.` }) : null,
|
||
f.addServer && REG.serverFile ? el("p", { class: "muted", text: `+ item_code = ${f.code} in ${REG.serverFile} (the old one is kept as .bak).` }) : el("p", { class: "warn", text: "The server's sp2_costumemanager.ini isn't changed: add the code there yourself." }),
|
||
]);
|
||
if (!(await modal("Register the costume", body, [{ label: "Cancel", value: false }, { label: "Register", kind: "primary", value: true }]))) return;
|
||
loading(true, "Writing the costume list and the string tables…");
|
||
try {
|
||
const r = await call("cos:register", COS.src, entryOf(f));
|
||
REG.last = r;
|
||
for (const w of r.warnings) toast(w, true);
|
||
toast(`Registered ${f.code}: ${r.written.length} files written.`);
|
||
await regInfo(f.kind, true);
|
||
REG.form = null;
|
||
renderReg();
|
||
REG.last = r;
|
||
renderReg();
|
||
} finally {
|
||
loading(false);
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// save
|
||
// =====================================================================
|
||
/** Pixels of an image at a power-of-two size (≤ max). → { rgba, w, h } */
|
||
function pixels(img, max) {
|
||
const p2 = (n) => Math.min(max, 2 ** Math.round(Math.log2(Math.max(1, n))));
|
||
const w = p2(img.width), h = p2(img.height);
|
||
const c = new OffscreenCanvas(w, h);
|
||
const g = c.getContext("2d");
|
||
if (img instanceof ImageData) {
|
||
const t = new OffscreenCanvas(img.width, img.height);
|
||
t.getContext("2d").putImageData(img, 0, 0);
|
||
g.drawImage(t, 0, 0, w, h);
|
||
} else g.drawImage(img, 0, 0, w, h);
|
||
return { rgba: g.getImageData(0, 0, w, h).data, w, h };
|
||
}
|
||
/** Every file of the costume: [{ folder: mesh|material|texture, name, data }] */
|
||
/** The file materials the costume uses (body, then hands): texture k is <name>[_k+1].dds. */
|
||
const usedMats = () => [...new Set(PART_ORDER.flatMap((p) => COS.out[p]?.groups || []).map((g) => g.material).filter((m) => typeof m === "number"))];
|
||
function files() {
|
||
const o = COS.opts;
|
||
if (!anyOut() || !o.name) throw new Error("Nothing to save.");
|
||
const used = usedMats();
|
||
const out = [];
|
||
used.forEach((mi, k) => {
|
||
const m = COS.mats[mi];
|
||
let data;
|
||
if (m?.image && /\.dds$/i.test(m.image.name)) data = m.image.bytes;
|
||
else if (m?.img) {
|
||
const p = pixels(m.img, o.texMax);
|
||
data = CK.encodeDds(p.rgba, p.w, p.h);
|
||
} else {
|
||
const c = (m?.color || [0.8, 0.8, 0.8, 1]).map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255));
|
||
const px = new Uint8Array(8 * 8 * 4);
|
||
for (let i = 0; i < 64; i++) px.set([c[0], c[1], c[2], 255], i * 4);
|
||
data = CK.encodeDds(px, 8, 8);
|
||
}
|
||
out.push({ folder: "texture", name: texNameOf(k), data });
|
||
});
|
||
const meshes = [];
|
||
for (const [part, name] of PART_ORDER.filter((p) => COS.out[p]).map((p) => [COS.out[p], outName(p)])) {
|
||
const msh = LSF.buildMsh(part.data);
|
||
const mat = new TextEncoder().encode(
|
||
part.groups
|
||
.map((g) => {
|
||
const ls = lsMat(g.material);
|
||
if (!ls) return CK.materialText([texNameOf(used.indexOf(g.material))], { outline: o.outline });
|
||
// the hero's own body: its material as the game has it (the skin colour blend)
|
||
const block = ls.mesh.mat ? materialBlocks(ls.mesh.mat.text)[ls.sub] : null;
|
||
return block ? block.replace(/\r?\n/g, "\r\n") + "\r\n" : CK.materialText([ls.group?.texName || null], { outline: o.outline });
|
||
})
|
||
.join(""),
|
||
);
|
||
meshes.push({ folder: "mesh", name: `${name}.msh`, data: msh }, { folder: "material", name: `${name}.txt`, data: mat });
|
||
if (o.hw) out.push({ folder: "mesh", name: `${name}_hw.msh`, data: msh }, { folder: "material", name: `${name}_hw.txt`, data: mat });
|
||
}
|
||
out.unshift(...meshes);
|
||
return out;
|
||
}
|
||
const REF = { mesh: "human_body.msh", material: "human_body.txt", texture: "human_body.dds" };
|
||
async function saveIntoPacks() {
|
||
const list = files();
|
||
// a file that's already in the packs is replaced where it is; a new one goes next to the hero body's
|
||
const plan = [];
|
||
for (const f of list) {
|
||
const hit = await readNamed(f.name, f.folder);
|
||
const ref = hit || (await readNamed(REF[f.folder], f.folder));
|
||
if (ref?.where?.kind !== "pack") throw new Error(`No pack with resource/${f.folder} (or the source is an extracted folder): use Save to a folder.`);
|
||
const dir = ref.where.entry.replace(/[^/\\]+$/, "");
|
||
plan.push({ f, where: { kind: "pack", file: ref.where.file, entry: hit ? ref.where.entry : `${dir}${f.name}`, create: !hit }, replace: !!hit });
|
||
}
|
||
const replaced = plan.filter((p) => p.replace);
|
||
const body = el("div", {}, [
|
||
el("p", { text: `${plan.length} files go into the packs:` }),
|
||
el("div", { class: "mono ans-note" }, plan.map((p) => el("div", { text: `${p.replace ? "replace" : "new "} ${p.where.file.split(/[\\/]/).pop()} ${p.where.entry}` }))),
|
||
replaced.length ? el("p", { class: "warn", text: `${replaced.length} existing file(s) are replaced — every costume that uses them changes in the game.` }) : null,
|
||
el("p", { class: "muted", text: `In the game: register it (Register in game) — ${PART_ORDER.filter((p) => COS.out[p]).map((p) => `${PARTS[p].key} = ${outName(p)}`).join(", ")}.` }),
|
||
]);
|
||
if (!(await modal("Save the costume into the packs", body, [{ label: "Cancel", value: false }, { label: "Save", kind: "primary", value: true }]))) return;
|
||
loading(true, "Saving…");
|
||
try {
|
||
for (const p of plan) await call("mdl:writeBack", p.where, p.f.data);
|
||
kit.clearTextures();
|
||
COS.dirty = false;
|
||
$("cos-dirty").hidden = true;
|
||
toast(`Saved ${plan.length} files into the packs.`);
|
||
loadList();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
} finally {
|
||
loading(false);
|
||
}
|
||
}
|
||
async function saveToFolder() {
|
||
const list = files();
|
||
const folder = await call("dialog:folder", "Folder to save the costume in (resource/… is made inside)");
|
||
if (!folder) return;
|
||
try {
|
||
for (const sub of ["mesh", "material", "texture"]) {
|
||
const fs = list.filter((f) => f.folder === sub);
|
||
if (fs.length) await call("mdl:writeFiles", `${folder}/resource/${sub}`, fs.map((f) => ({ name: f.name, data: f.data })));
|
||
}
|
||
COS.dirty = false;
|
||
$("cos-dirty").hidden = true;
|
||
toast(`Saved ${list.length} files in ${folder}\\resource.`);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
$("cos-save").onclick = (e) =>
|
||
showMenu(
|
||
[
|
||
{ label: `Save into the packs (${PART_ORDER.filter((p) => COS.out[p]).map(outName).join(", ")})`, icon: "box", disabled: !anyOut(), run: () => saveIntoPacks().catch((err) => toast(err.message, true)) },
|
||
{ label: "Save to a folder…", icon: "download", disabled: !anyOut(), run: () => saveToFolder().catch((err) => toast(err.message, true)) },
|
||
],
|
||
{ anchor: e.currentTarget },
|
||
);
|
||
|
||
// =====================================================================
|
||
// the body for Blender
|
||
// =====================================================================
|
||
async function png(img) {
|
||
const c = new OffscreenCanvas(img.width, img.height);
|
||
const g = c.getContext("2d");
|
||
if (img instanceof ImageData) g.putImageData(img, 0, 0);
|
||
else g.drawImage(img, 0, 0);
|
||
return new Uint8Array(await (await c.convertToBlob({ type: "image/png" })).arrayBuffer());
|
||
}
|
||
/** What is shown, as a rigged .glb. → { bytes, meshes } */
|
||
async function bodyGlb() {
|
||
const shown = allMeshes().filter((m) => !m.hidden);
|
||
const images = [], meshes = [];
|
||
for (const m of shown) {
|
||
const p = CK.posed(COS.sk, COS.P, m.data);
|
||
const groups = [];
|
||
for (const [gi, g] of m.groups.entries()) {
|
||
let image = null;
|
||
const ls = m.costume ? lsMat(m.built.groups[gi].material) : null;
|
||
const img = ls ? ls.group?.texInfo?.img : m.costume ? COS.mats[m.built.groups[gi].material]?.img : g.texInfo?.img;
|
||
const nm = m.costume && !ls ? `${m.name}_${gi + 1}.png` : (ls ? ls.group : g)?.texName ? (ls ? ls.group : g).texName.replace(/\.[^.]+$/, ".png") : null;
|
||
if (img && nm) {
|
||
if (!images.some((x) => x.name === nm)) images.push({ name: nm, png: await png(img) });
|
||
image = nm;
|
||
}
|
||
groups.push({ start: g.start, count: g.count, image });
|
||
}
|
||
meshes.push({ name: m.name, ...p, groups });
|
||
}
|
||
return { bytes: CK.exportGlb(COS.sk, COS.P, meshes, images), meshes };
|
||
}
|
||
async function exportBody() {
|
||
if (!COS.sk && !(await chooseSource())) return;
|
||
if (!COS.sk) return;
|
||
const { bytes, meshes } = await bodyGlb();
|
||
const file = await call("dialog:saveAs", `lostsaga_${COS.sex}_body.glb`, "glb");
|
||
if (!file) return;
|
||
await call("mdl:writeBack", { kind: "file", file }, bytes);
|
||
toast(`Saved ${file}: ${meshes.length} meshes + the ${COS.sk.data.bones.length}-bone skeleton. In Blender: File → Import → glTF 2.0.`);
|
||
}
|
||
$("cos-blender").onclick = () => exportBody().catch((err) => toast(err.message, true));
|
||
|
||
// =====================================================================
|
||
// keys + first show
|
||
// =====================================================================
|
||
document.addEventListener("keydown", (e) => {
|
||
if (!visible() || e.target.closest?.("input, textarea, select") || !$("modal").hidden) return;
|
||
if (e.key === " ") {
|
||
e.preventDefault();
|
||
$("cos-play").onclick();
|
||
}
|
||
});
|
||
// the hero appears when the tab is first opened with the profile's packs
|
||
new MutationObserver(() => {
|
||
if (visible() && !COS.src && !view) {
|
||
const s = profileSource();
|
||
if (s) setSource(s);
|
||
}
|
||
}).observe($("view-cos"), { attributes: true, attributeFilter: ["class"] });
|
||
Object.assign(COS, { REG, regInfo, importFile, exportBody, bodyGlb, files, rebuild, playAni, setSource, loadHero });
|
||
renderSide();
|
||
})();
|