848 lines
52 KiB
JavaScript
848 lines
52 KiB
JavaScript
"use strict";
|
||
|
||
// Experimental editor for the existing mercenary INIs. Edits are exported as loose files.
|
||
(() => {
|
||
const HERO = { src: null, rows: [], id: "", name: "", docs: {}, tab: "gear", assetRows: {}, previewReady: false, actionToken: 0,
|
||
practice: { active: false, keys: new Set(), pos: [0, 0, 0], yaw: 0, vy: 0, grounded: true, dashUntil: 0, hp: 100, target: [0, 0, 220], combo: 0, comboAt: 0, directions: [], lastTap: {}, lastMotion: "", busy: false, chord: false } };
|
||
window.HERO = HERO;
|
||
HERO.openTab = (t) => document.querySelector(`[data-hero-tab="${t}"]`)?.click();
|
||
const kinds = ["item", "skill", "attack", "buff"];
|
||
const raw = (bytes) => {
|
||
let out = "";
|
||
for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192));
|
||
return out;
|
||
};
|
||
const doc = (kind) => HERO.docs[kind] && HeroIni.parse(HERO.docs[kind].text);
|
||
const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : ""; };
|
||
// web fix: STR(n) → the text the game shows (text table key ini_<file stem>::<section>_<n>)
|
||
const shown = (kind, section, v) => {
|
||
const m = /^\s*STR\(\s*(\d+)/i.exec(v || "");
|
||
return m ? HERO.strs?.[`ini_${HERO.id}_${kind}::${section}_${m[1]}`.toLowerCase()] || v : v;
|
||
};
|
||
const strText = (kind, section, key) => shown(kind, section, value(kind, section, key));
|
||
const dirty = () => kinds.filter((kind) => HERO.docs[kind]?.text !== HERO.docs[kind]?.saved);
|
||
const opt = (value, title, sub = "") => ({ value, title, sub });
|
||
|
||
function updateStatus() {
|
||
const changed = dirty();
|
||
$("hero-dirty").hidden = !changed.length;
|
||
$("hero-export").disabled = !changed.length;
|
||
$("hero-export-status").textContent = changed.length
|
||
? `Ready to export: ${changed.map((x) => `${HERO.id}_${x}.ini`).join(", ")}`
|
||
: "Only changed INI files will be exported. The game packs remain untouched.";
|
||
}
|
||
async function setSource(src) {
|
||
setPractice(false);
|
||
const d = await call("mdl:describe", src);
|
||
HERO.src = { ...src, label: d.label };
|
||
HERO.rows = await call("hero:list", HERO.src);
|
||
HERO.docs = {}; HERO.id = ""; HERO.name = "";
|
||
HERO.previewReady = false;
|
||
$("hero-src-label").textContent = HERO.src.kind === "dir" ? "Extracted folder" : HERO.src.kind === "packs" ? "Pack folder" : "Profile packs";
|
||
$("hero-title").textContent = "Hero Creator · experimental";
|
||
$("hero-work").hidden = true; $("hero-empty").hidden = false;
|
||
$("hero-preview").disabled = true;
|
||
HERO.assetRows = {};
|
||
renderList(); updateStatus();
|
||
}
|
||
async function chooseSource() {
|
||
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||
const options = [
|
||
...profileSourceOption(),
|
||
...sessions.map((s) => opt({ kind: "session", sessionId: s.id, name: s.name }, `Packs next to ${s.name}`, s.file)),
|
||
opt("packs", "Folder with .iop packs…", "Client folder with rs.iop and resource packs"),
|
||
opt("dir", "Extracted folder…", "Folder containing config/mercenary"),
|
||
];
|
||
if (!options.length) return toast("No asset source found.", true);
|
||
let choice = options[0].value;
|
||
const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [
|
||
el("input", { type: "radio", name: "herosrc", checked: i === 0, onchange: () => { choice = o.value; } }),
|
||
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
|
||
])));
|
||
if (!(await modal("Hero source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return;
|
||
if (choice === "packs" || choice === "dir") {
|
||
const folder = await call("dialog:folder", "Choose the game resource folder");
|
||
if (!folder) return;
|
||
choice = { kind: choice, folder };
|
||
}
|
||
try { await setSource(choice); } catch (err) { toast(err.message, true); }
|
||
}
|
||
$("hero-src").onclick = chooseSource;
|
||
|
||
function renderList() {
|
||
const q = $("hero-search").value.trim().toLowerCase();
|
||
const rows = HERO.rows.filter(([id, name]) => !q || id.includes(q) || name.toLowerCase().includes(q));
|
||
$("hero-count").textContent = String(rows.length);
|
||
$("hero-list").replaceChildren(...rows.map(([id, name]) => el("button", {
|
||
class: `hero-row${id === HERO.id ? " on" : ""}`, onclick: () => openHero(id, name),
|
||
}, [el("span", { class: "hero-id", text: id }), el("span", { class: "hero-row-name", text: name })])));
|
||
}
|
||
$("hero-search").oninput = renderList;
|
||
HERO.setSource = (src) => setSource(src);
|
||
HERO.open = (id, name) => openHero(id, name || id);
|
||
|
||
async function openHero(id, name) {
|
||
if (dirty().length && !(await confirmBox("Unsaved Hero Creator changes", "Discard your current edits?", "Discard", "danger"))) return;
|
||
try {
|
||
setPractice(false);
|
||
const docs = {};
|
||
for (const kind of kinds) {
|
||
const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_${kind}.ini`);
|
||
if (hit) docs[kind] = { text: raw(hit.bytes), saved: raw(hit.bytes) };
|
||
}
|
||
if (!docs.item) throw new Error(`No ${id}_item.ini in this source`);
|
||
try { HERO.strs = await call("text:forStems", HERO.src, Object.keys(docs).map((k) => `${id}_${k}`)); } catch { HERO.strs = {}; }
|
||
HERO.id = id; HERO.name = name; HERO.docs = docs; HERO.tab = "gear";
|
||
HERO.previewReady = false;
|
||
$("hero-title").textContent = `${id} · ${name}`;
|
||
$("hero-name").textContent = `${id} · ${name}`;
|
||
$("hero-summary").textContent = "Edit gear appearance, move animations, skills and effect references. Export writes loose INI files for testing.";
|
||
$("hero-work").hidden = false; $("hero-empty").hidden = true;
|
||
$("hero-preview").disabled = false;
|
||
$("hero-practice").disabled = false;
|
||
for (const b of document.querySelectorAll("[data-hero-tab]")) b.classList.toggle("on", b.dataset.heroTab === "gear");
|
||
renderList(); renderContent(); renderActions(); updateStatus();
|
||
buildPreview();
|
||
} catch (err) { toast(err.message, true); }
|
||
}
|
||
for (const b of document.querySelectorAll("[data-hero-tab]")) b.onclick = () => {
|
||
HERO.tab = b.dataset.heroTab;
|
||
for (const x of document.querySelectorAll("[data-hero-tab]")) x.classList.toggle("on", x === b);
|
||
renderContent();
|
||
};
|
||
|
||
function edit(kind, section, key, newValue) {
|
||
if (/[\r\n]/.test(newValue) || [...newValue].some((c) => c.charCodeAt(0) > 255)) return toast("Use a single-line 8-bit INI value.", true);
|
||
const d = HERO.docs[kind];
|
||
d.text = HeroIni.set(d.text, section, key, newValue);
|
||
if (kind === "item" || kind === "skill" || kind === "attack") renderActions();
|
||
if (kind === "item" && isMesh(key) && /^(?:equip|body)_mesh/i.test(key)) {
|
||
HERO.previewReady = false;
|
||
clearTimeout(HERO.previewTimer);
|
||
HERO.previewTimer = setTimeout(buildPreview, 250);
|
||
}
|
||
updateStatus();
|
||
}
|
||
const isMesh = (key) => /^(equip_mesh\d*|body_mesh\d*|field_mesh\d*|collision_mesh|mesh_file)$/i.test(key);
|
||
const isMove = (_key, val) => /\.ani(?:\s|$)/i.test(val);
|
||
const isFx = (key, val) => /effect/i.test(key) && /\.txt(?:\s|$)/i.test(val);
|
||
const assetType = (key, val) => isMesh(key) ? "mesh" : /\.ani(?:\s|$)/i.test(val) ? "animation" : /\.txt(?:\s|$)/i.test(val) ? "effect" : "";
|
||
|
||
function fieldRow(kind, section, f) {
|
||
const input = el("input", { type: "text", value: f.value, spellcheck: "false", title: `${kind}.ini [${section}] ${f.key}` });
|
||
input.addEventListener("change", () => edit(kind, section, f.key, input.value));
|
||
input.addEventListener("focus", () => {
|
||
$("hero-field-detail").textContent = `${HERO.id}_${kind}.ini · [${section}] · ${f.key}`;
|
||
});
|
||
const type = assetType(f.key, f.value);
|
||
const buttons = [];
|
||
if (type) buttons.push(el("button", { class: "iconbtn only", title: `Choose ${type}`, text: "…", onclick: async () => {
|
||
const picked = await pickAsset(type, input.value);
|
||
if (picked) { input.value = picked; edit(kind, section, f.key, picked); }
|
||
}}));
|
||
if (type) buttons.push(el("button", { class: "iconbtn only", title: `Preview ${type}`, text: "View", onclick: () => previewAsset(type, input.value) }));
|
||
return el("div", { class: "hero-field" }, [el("label", { text: f.key }), input, el("span", {}, buttons)]);
|
||
}
|
||
function card(title, fields, opts = {}) {
|
||
const head = el("div", { class: "hero-card-head" }, [el("h3", { text: title }), el("div", { class: "spacer" }), ...(opts.actions || [])]);
|
||
return el("section", { class: "hero-card" }, [head, el("div", { class: "hero-fields" }, fields)]);
|
||
}
|
||
const itemSections = () => doc("item")?.sections.filter((s) => /^item\d+$/i.test(s.name)) || [];
|
||
|
||
function renderGear() {
|
||
return itemSections().map((s) => {
|
||
const name = strText("item", s.name, "name");
|
||
const slot = value("item", s.name, "item_type") || s.name;
|
||
const fields = s.fields.filter((f) => isMesh(f.key));
|
||
const donor = el("button", { class: "iconbtn", text: "Copy appearance…", onclick: () => copyAppearance(s.name) });
|
||
return card(`${slot} · ${name}`, fields.map((f) => fieldRow("item", s.name, f)), { actions: [donor] });
|
||
});
|
||
}
|
||
function renderMoves() {
|
||
const cards = [];
|
||
for (const s of itemSections()) {
|
||
const fields = s.fields.filter((f) => isMove(f.key, f.value));
|
||
if (fields.length) cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${strText("item", s.name, "name")}`, fields.map((f) => fieldRow("item", s.name, f))));
|
||
}
|
||
return cards;
|
||
}
|
||
function renderSkills() {
|
||
const d = doc("skill");
|
||
if (!d) return [];
|
||
return d.sections.filter((s) => /^skill\d+$/i.test(s.name)).map((s) => {
|
||
const fields = s.fields.filter((f) => isMove(f.key, f.value) || isFx(f.key, f.value) || /^(name|desc_name|need_gauge|type|sub_type)$/i.test(f.key));
|
||
return card(`${s.name} · ${strText("skill", s.name, "desc_name") || strText("skill", s.name, "name")}`, fields.map((f) => fieldRow("skill", s.name, f)));
|
||
});
|
||
}
|
||
function renderEffects() {
|
||
const cards = [];
|
||
for (const kind of kinds) {
|
||
const d = doc(kind);
|
||
if (!d) continue;
|
||
for (const s of d.sections) {
|
||
const fields = s.fields.filter((f) => isFx(f.key, f.value));
|
||
if (fields.length) cards.push(card(`${kind.toUpperCase()} · [${s.name}]`, fields.map((f) => fieldRow(kind, s.name, f))));
|
||
}
|
||
}
|
||
return cards;
|
||
}
|
||
// =====================================================================
|
||
// Flow: input → animation (.ani events) → hits (fire_s n ↔ attack_type n) → [attributeN] → effects /
|
||
// sounds / buffs, with the checks the client would fail on (ioBaseChar::RefreshFireTimeListWithBase,
|
||
// ioPlayStage::AddWeaponExplosionEffect, ioClientAniEventHandler)
|
||
// =====================================================================
|
||
const FLOW_LABELS = { normal_attack: "D combo", dash_attack: "Dash + D", extra_dash_attack: "Dash + D (extra)", extend_dash_attack: "Dash + D (extended)", jump_attack: "Air + D", extend_jump_attack: "Air + D (extended)", land_jump_attack: "Landing + D", counter_attack: "Counter", extend_attack: "Hold D", normal_charged: "Charged D", jump_charged: "Charged air D", dash_jump_attack: "Dash jump + D", retreat_attack: "Retreat attack", attack: "Skill", jump_attack_skill: "Skill in the air" };
|
||
const ATTR_TYPES = { 0: "dummy", 1: "contact (hitbox)", 2: "missile", 3: "throw", 4: "bomb", 5: "mine" };
|
||
const flowNames = new Map();
|
||
function flowList(folder) {
|
||
const key = `${folder}|${HERO.src?.folder || HERO.src?.sessionId || ""}`;
|
||
if (!flowNames.has(key)) flowNames.set(key, call("mdl:names", HERO.src, folder).then((l) => new Set(l.map((n) => n.toLowerCase().split("/").pop()))).catch(() => new Set()));
|
||
return flowNames.get(key);
|
||
}
|
||
const fname = (v) => String(v || "").replace(/\s*;.*$/, "").trim().split(/\s+/)[0];
|
||
/** Every attacking action of the hero: { kind, section, label, ani, rate, types: [{ id, wounded }] } */
|
||
function flowActions() {
|
||
const out = [];
|
||
for (const kind of ["item", "skill"]) {
|
||
const d = doc(kind);
|
||
if (!d) continue;
|
||
for (const s of d.sections) {
|
||
const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value);
|
||
const title = kind === "skill" ? `${s.name} · ${get("desc_name") && !/^STR\(/.test(get("desc_name")) ? get("desc_name") : get("name") || ""}` : `${get("item_type") || s.name} · ${s.name}`;
|
||
for (const f of s.fields) {
|
||
const m = /^(.*)_type_count$/i.exec(f.key);
|
||
if (!m) continue;
|
||
const p = m[1], count = Number.parseInt(fname(f.value), 10) || 0;
|
||
const aniKey = kind === "skill" && /^attack$/i.test(p) ? "skill_motion" : kind === "skill" && /^jump_attack$/i.test(p) ? "jump_skill_motion" : p;
|
||
const ani = get(aniKey);
|
||
if (!/\.ani$/i.test(ani)) continue;
|
||
const rate = Number.parseFloat(get(`${p}_time_rate`) || (kind === "skill" ? get(aniKey === "jump_skill_motion" ? "jump_animation_time_rate" : "animation_time_rate") : "") || "1") || 1;
|
||
const types = Array.from({ length: count }, (_, i) => ({ id: get(`${p}_type${i + 1}`), wounded: get(`${p}_type${i + 1}_wounded`) }));
|
||
const base = p.replace(/\d+$/, ""), num = (/(\d+)$/.exec(p) || [])[1];
|
||
const label = kind === "skill" ? `${title}${/^jump_attack$/i.test(p) ? " (in the air)" : /^attack$/i.test(p) ? "" : ` (${p})`}` : `${FLOW_LABELS[base] || p}${num ? ` ${Number(num)}` : ""}`;
|
||
out.push({ kind, section: s.name, title, label, key: p, aniKey, ani, rate, types, preDelay: kind === "skill" ? get("pre_delay_animation") : "", preEffect: kind === "skill" ? get("pre_delay_effect") : "", gauge: kind === "skill" ? get("need_gauge") : "" });
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
function flowAttr(id) {
|
||
const d = doc("attack");
|
||
const s = d && HeroIni.section(d, `attribute${id}`);
|
||
if (!s) return null;
|
||
const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value).replace(/^(-?[0-9.]+)f$/i, "$1");
|
||
const buffs = Array.from({ length: Number.parseInt(get("target_buff_cnt"), 10) || 0 }, (_, i) => get(`target_buff${i + 1}`)).filter(Boolean);
|
||
return { id, type: Number(get("type") || 0), damage: get("damage_rate"), push: get("push_power"), blow: get("blow_power"), airBlow: get("air_blow_power"),
|
||
explosion: get("explosion_effect"), extra: get("extra_explosion_effect"), weaponEffect: get("weapon_effect"), wounded: get("wounded_sound"), defensed: get("defensed_sound"),
|
||
buffs, breakBuff: get("defense_break_buff"), shake: get("shake_camera") };
|
||
}
|
||
const aniCache = new Map();
|
||
async function flowAni(name) {
|
||
const key = name.toLowerCase();
|
||
if (!aniCache.has(key)) aniCache.set(key, call("mdl:read", HERO.src, name, "animation", true).then((h) => (h ? LSF.parseAni(h.bytes) : null)).catch(() => null));
|
||
return aniCache.get(key);
|
||
}
|
||
let flowAudio = null;
|
||
async function flowPlay(path) {
|
||
try {
|
||
const h = await call("mdl:readPath", HERO.src, `wave/${path}`) || await call("mdl:readPath", HERO.src, `wave/${path.replace(/(\.\w+)$/, "_1$1")}`);
|
||
if (!h) return toast(`${path} is not in the packs`, true);
|
||
flowAudio ??= new AudioContext();
|
||
const b = await flowAudio.decodeAudioData(h.bytes.slice().buffer);
|
||
const s = flowAudio.createBufferSource(); s.buffer = b; s.connect(flowAudio.destination); s.start();
|
||
} catch (err) { toast(err.message, true); }
|
||
}
|
||
async function flowOpenAni(name) {
|
||
document.querySelector('[data-view="ans"]').click();
|
||
if (!window.ANS?.setSource) return;
|
||
if (ANS.src?.folder !== HERO.src.folder || ANS.src?.kind !== HERO.src.kind) await ANS.setSource(HERO.src, { quiet: true });
|
||
for (let i = 0; i < 50 && !(ANS.rows && ANS.rows.length); i++) await new Promise((r) => setTimeout(r, 100));
|
||
const row = ANS.rows.find((r) => r[3] === "ani" && `${r[1]}.ani`.toLowerCase() === name.toLowerCase());
|
||
if (!row) return toast(`${name} is not in the Anim Studio list`, true);
|
||
await ANS.openRow(row);
|
||
if (ANS.setHit) ANS.setHit({ hero: HERO.id, weapon: "" });
|
||
}
|
||
async function flowOpenFx(name) {
|
||
try {
|
||
const h = await call("mdl:read", HERO.src, name, "effect", true);
|
||
if (!h) return toast(`${name} is not in the packs`, true);
|
||
let text = "";
|
||
for (let i = 0; i < h.bytes.length; i += 8192) text += String.fromCharCode(...h.bytes.subarray(i, i + 8192));
|
||
document.querySelector('[data-view="fxlab"]').click();
|
||
FXL.openScript(text, name, h.where);
|
||
} catch (err) { toast(err.message, true); }
|
||
}
|
||
const chip = (text, cls = "", attrs = {}) => el("span", { class: `flow-chip ${cls}`, text, ...attrs });
|
||
function fileChip(name, kind, exists) {
|
||
if (!name || /^none$/i.test(name)) return null;
|
||
const missing = exists && !exists.has(name.toLowerCase().split("/").pop()) && !(kind === "sound" && exists.has(name.toLowerCase().split("/").pop().replace(/(\.\w+)$/, "_1$1")));
|
||
const kids = [el("span", { text: name })];
|
||
if (kind === "sound") kids.push(el("button", { class: "flow-mini", title: "Listen", onclick: () => flowPlay(name) }, [icon("play")]));
|
||
if (kind === "effect") kids.push(el("button", { class: "flow-mini", title: "Open in FX Lab", onclick: () => flowOpenFx(name) }, [icon("brush")]));
|
||
return el("span", { class: `flow-chip file ${kind}${missing ? " bad" : ""}`, title: missing ? `${name}: not in the packs` : name }, kids);
|
||
}
|
||
function renderFlow() {
|
||
const box = el("div", { class: "flow" }, [el("div", { class: "muted", text: "Reading the animations…" })]);
|
||
const seq = (HERO.flowSeq = (HERO.flowSeq || 0) + 1);
|
||
(async () => {
|
||
const [effects, waves] = await Promise.all([flowList("effect"), flowList("wave")]);
|
||
const acts = flowActions();
|
||
const cards = [];
|
||
let problems = 0;
|
||
for (const a of acts) {
|
||
const data = await flowAni(a.ani);
|
||
if (seq !== HERO.flowSeq) return;
|
||
const ev = (data?.events || []).map((e) => ({ t: e.time, type: e.type.text.toLowerCase(), info: e.sub.text.trim() })).sort((x, y) => x.t - y.t);
|
||
const starts = ev.filter((e) => e.type === "fire_s"), ends = ev.filter((e) => e.type === "fire_e");
|
||
const ok = data && starts.length === a.types.length;
|
||
if (!ok) problems++;
|
||
const rateT = (t) => Math.round(t * a.rate);
|
||
const others = ev.filter((e) => !/^fire_[se]$/.test(e.type) && /sound|effect|face_change|trail_s|auto_shake/.test(e.type));
|
||
const hits = a.types.map((ty, i) => {
|
||
const s = starts[i], e = ends[i], at = flowAttr(ty.id);
|
||
const box2 = s ? (s.info.split(/\s+/)[0] || "front of the body") : "—";
|
||
return el("div", { class: `flow-hit${!s ? " bad" : ""}` }, [
|
||
el("div", { class: "flow-hit-head" }, [
|
||
chip(`hit ${i + 1}`, "n"),
|
||
chip(s ? `${rateT(s.t)}–${rateT(e ? e.t : s.t)} ms` : "no fire_s", s ? "" : "bad"),
|
||
chip(box2, "box"),
|
||
el("span", { class: "flow-arrow", text: "→" }),
|
||
chip(`attribute${ty.id}`, at ? "attr" : "bad"),
|
||
at ? chip(ATTR_TYPES[at.type] || `type ${at.type}`) : null,
|
||
at?.damage ? chip(`damage ×${at.damage}`) : null,
|
||
at && (at.push || at.blow) ? chip(`push ${at.push || 0} · blow ${at.blow || 0}${at.airBlow ? ` · air ${at.airBlow}` : ""}`) : null,
|
||
]),
|
||
at ? el("div", { class: "flow-hit-body" }, [
|
||
at.explosion ? el("span", { class: "flow-l" }, [el("em", { text: "on target" }), fileChip(at.explosion, "effect", effects)]) : null,
|
||
at.extra ? el("span", { class: "flow-l" }, [el("em", { text: "attacker only" }), fileChip(at.extra, "effect", effects)]) : null,
|
||
at.weaponEffect ? el("span", { class: "flow-l" }, [el("em", { text: "projectile" }), fileChip(at.weaponEffect, "effect", effects)]) : null,
|
||
at.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "hit sound" }), fileChip(at.wounded, "sound", waves)]) : null,
|
||
at.defensed ? el("span", { class: "flow-l" }, [el("em", { text: "blocked sound" }), fileChip(at.defensed, "sound", waves)]) : null,
|
||
...at.buffs.map((b) => el("span", { class: "flow-l" }, [el("em", { text: "buff on target" }), chip(b, "buff")])),
|
||
at.breakBuff ? el("span", { class: "flow-l" }, [el("em", { text: "guard break" }), chip(at.breakBuff, "buff")]) : null,
|
||
ty.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "target plays" }), chip(ty.wounded)]) : null,
|
||
]) : el("div", { class: "flow-hit-body bad", text: `[attribute${ty.id}] is not in ${HERO.id}_attack.ini` }),
|
||
]);
|
||
});
|
||
if (starts.length > a.types.length) hits.push(el("div", { class: "flow-hit bad" }, [chip(`${starts.length - a.types.length} extra fire_s without an attack type`, "bad")]));
|
||
const evChips = others.map((e) => {
|
||
const kind = /sound/.test(e.type) ? "sound" : /effect/.test(e.type) ? "effect" : "";
|
||
return el("span", { class: "flow-l" }, [el("em", { text: `${rateT(e.t)} ms ${e.type}` }), kind ? fileChip(e.info.split(/\s+/)[0], kind, kind === "sound" ? waves : effects) : chip(e.info || "—")]);
|
||
});
|
||
cards.push(el("section", { class: `flow-card${ok ? "" : " bad"}` }, [
|
||
el("div", { class: "flow-head" }, [
|
||
el("b", { text: a.label }),
|
||
el("span", { class: "muted", text: `${a.title} · ${a.kind}.ini ${a.key}` }),
|
||
el("div", { class: "spacer" }),
|
||
el("span", { class: `flow-check ${ok ? "ok" : "bad"}`, text: !data ? "✗ animation not in the packs" : ok ? `✓ ${starts.length} fire_s = ${a.types.length} attack types` : `✗ ${starts.length} fire_s ≠ ${a.types.length} attack types (the attack never fires)` }),
|
||
]),
|
||
el("div", { class: "flow-row" }, [
|
||
a.preDelay ? el("div", { class: "flow-step" }, [el("em", { text: "ready" }), chip(a.preDelay), a.preEffect ? fileChip(a.preEffect, "effect", effects) : null]) : null,
|
||
a.preDelay ? el("span", { class: "flow-arrow", text: "→" }) : null,
|
||
el("div", { class: "flow-step" }, [
|
||
el("em", { text: `animation${a.rate !== 1 ? ` ×${a.rate}` : ""}${data ? ` · ${rateT(data.total || 0)} ms` : ""}` }),
|
||
el("span", { class: `flow-chip ani${data ? "" : " bad"}` }, [el("span", { text: a.ani }), el("button", { class: "flow-mini", title: "Open in Anim Studio (with the hitbox check)", onclick: () => flowOpenAni(a.ani) }, [icon("bone")])]),
|
||
]),
|
||
evChips.length ? el("span", { class: "flow-arrow", text: "·" }) : null,
|
||
evChips.length ? el("div", { class: "flow-step flow-events" }, evChips) : null,
|
||
]),
|
||
el("div", { class: "flow-hits" }, hits.length ? hits : [el("div", { class: "muted", text: "No attack types." })]),
|
||
]));
|
||
}
|
||
if (seq !== HERO.flowSeq) return;
|
||
box.replaceChildren(
|
||
el("div", { class: `flow-summary ${problems ? "bad" : "ok"}`, text: `${acts.length} attacking actions · ${problems ? `${problems} with a fire_s / attack type mismatch or a missing animation` : "all fire_s counts match"}. Red files are not in the packs.` }),
|
||
...cards,
|
||
);
|
||
})().catch((err) => box.replaceChildren(el("div", { class: "bad", text: err.message })));
|
||
return [box];
|
||
}
|
||
function renderContent() {
|
||
if (!HERO.id) return;
|
||
const cards = HERO.tab === "gear" ? renderGear() : HERO.tab === "moves" ? renderMoves() : HERO.tab === "skills" ? renderSkills() : HERO.tab === "flow" ? renderFlow() : renderEffects();
|
||
$("hero-content").replaceChildren(...(cards.length ? cards : [el("div", { class: "hero-empty-note", text: "No matching fields in this hero's config." })]));
|
||
}
|
||
|
||
async function copyAppearance(section) {
|
||
const idInput = el("input", { type: "text", placeholder: "Hero ID, e.g. 052", spellcheck: "false" });
|
||
if (!(await modal("Copy gear appearance", [
|
||
el("p", { text: "Copy mesh references from the same gear slot of another hero. Item codes and combat data stay with the base hero." }),
|
||
el("label", { text: `Source hero for ${section}` }), idInput,
|
||
], [{ label: "Cancel", value: false }, { label: "Copy meshes", kind: "primary", value: true }]))) return;
|
||
const id = idInput.value.trim().padStart(3, "0");
|
||
if (!/^\d{3}$/.test(id)) return toast("Enter a 3-digit hero ID.", true);
|
||
try {
|
||
const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_item.ini`);
|
||
if (!hit) throw new Error(`Hero ${id} gear config was not found`);
|
||
const donor = HeroIni.parse(raw(hit.bytes)), s = HeroIni.section(donor, section);
|
||
if (!s) throw new Error(`Donor has no [${section}]`);
|
||
const meshFields = s.fields.filter((f) => isMesh(f.key));
|
||
if (!meshFields.length) throw new Error(`Donor [${section}] has no mesh references`);
|
||
for (const f of meshFields) edit("item", section, f.key, f.value);
|
||
renderContent(); toast(`Copied ${meshFields.length} mesh fields from hero ${id}.`);
|
||
} catch (err) { toast(err.message, true); }
|
||
}
|
||
|
||
async function assetsFor(type) {
|
||
if (HERO.assetRows[type]) return HERO.assetRows[type];
|
||
const rows = type === "effect" ? await call("fx:list", HERO.src) : await call("mdl:list", HERO.src);
|
||
HERO.assetRows[type] = rows.map((r) => r[1].split("/").pop()).filter((name) =>
|
||
type === "effect" ? /\.txt$/i.test(name) : type === "animation" ? /\.ani$/i.test(name) : /\.msh$/i.test(name));
|
||
return HERO.assetRows[type];
|
||
}
|
||
async function pickAsset(type, current) {
|
||
try {
|
||
const rows = await assetsFor(type);
|
||
const search = el("input", { type: "search", value: current.replace(/\.[^.]+$/, ""), placeholder: `Search ${type}s`, spellcheck: "false" });
|
||
const list = el("div", { class: "hero-picker-list" });
|
||
let chosen = "";
|
||
const render = () => {
|
||
const q = search.value.trim().toLowerCase();
|
||
list.replaceChildren(...rows.filter((x) => !q || x.toLowerCase().includes(q)).slice(0, 100).map((name) => el("button", {
|
||
class: `hero-picker-row${name === chosen ? " on" : ""}`, text: name,
|
||
onclick: () => { chosen = name; render(); }, ondblclick: () => { chosen = name; $("modal-actions").lastChild.click(); },
|
||
})));
|
||
};
|
||
search.oninput = render; render();
|
||
const ok = await modal(`Choose ${type}`, [search, list], [{ label: "Cancel", value: false }, { label: "Use selected", kind: "primary", value: true }]);
|
||
return ok ? chosen : "";
|
||
} catch (err) { toast(err.message, true); return ""; }
|
||
}
|
||
async function previewAsset(type, name) {
|
||
if (!name) return;
|
||
try {
|
||
if (type === "effect") {
|
||
await FXE.previewAsset(HERO.src, name);
|
||
} else if (type === "animation") {
|
||
await MDL.previewHero(HERO.src, gearMeshes(), name, { hair: helmetHair() });
|
||
} else {
|
||
await MDL.previewAsset(HERO.src, name, type);
|
||
}
|
||
} catch (err) { toast(err.message, true); }
|
||
}
|
||
// web fix: the helmet section (item_type HELMET) and its hair rule for the man body (default 0 = half hair)
|
||
function helmetHair() {
|
||
const s = itemSections().find((x) => /^helmet$/i.test(value("item", x.name, "item_type")) && value("item", x.name, "equip_mesh0"));
|
||
if (!s) return null;
|
||
const v = value("item", s.name, "hair_change_all0_hm");
|
||
return v === "" ? 0 : Number(v);
|
||
}
|
||
const gearMeshes = () => itemSections().flatMap((s) => s.fields.filter((f) => /^(?:equip|body)_mesh\d*$/i.test(f.key)).map((f) => f.value)).filter(Boolean);
|
||
async function buildPreview() {
|
||
if (!HERO.id) return;
|
||
const meshes = gearMeshes();
|
||
const animation = value("item", "item1", "delay_animation") || value("item", "item1", "normal_attack01");
|
||
$("hero-preview-status").textContent = "Loading body, gear and animation…";
|
||
$("hero-play-status").textContent = "Loading hero…";
|
||
try {
|
||
await MDL.previewHero(HERO.src, meshes, animation, { embedded: true, hair: helmetHair() });
|
||
if (HERO.practice.active) { MDL.setHeroTransform(HERO.practice.pos, HERO.practice.yaw); MDL.setHeroPracticeTarget(HERO.practice.target); }
|
||
HERO.previewReady = true;
|
||
$("hero-preview-status").textContent = `${meshes.length} gear meshes · ${animation || "default pose"}`;
|
||
$("hero-play-status").textContent = "Ready · drag to rotate, wheel to zoom";
|
||
} catch (err) { HERO.previewReady = false; $("hero-preview-status").textContent = err.message; $("hero-play-status").textContent = "Preview unavailable"; toast(err.message, true); }
|
||
}
|
||
$("hero-preview").onclick = buildPreview;
|
||
const ani = (s) => /\.ani$/i.test(String(s || "").trim()) ? String(s).trim() : "";
|
||
const fx = (s) => /\.txt$/i.test(String(s || "").trim()) ? String(s).trim() : "";
|
||
function attributeSummary(index) {
|
||
if (!index || !doc("attack")) return "";
|
||
const section = `attribute${index}`;
|
||
if (!HeroIni.section(doc("attack"), section)) return `attack ${index}`;
|
||
const damage = value("attack", section, "damage_rate");
|
||
const effect = fx(value("attack", section, "explosion_effect")) || fx(value("attack", section, "extra_explosion_effect"));
|
||
return `attack ${index}${damage ? ` · damage ${damage}` : ""}${effect ? ` · ${effect}` : ""}`;
|
||
}
|
||
function attackRefs(kind, section, prefix) {
|
||
const count = Math.min(20, Number.parseInt(value(kind, section, `${prefix}_type_count`) || value(kind, section, "attack_type_count"), 10) || 0);
|
||
return Array.from({ length: count }, (_, i) => value(kind, section, `${prefix}_type${i + 1}`) || value(kind, section, `attack_type${i + 1}`)).filter(Boolean);
|
||
}
|
||
function actionEffect(kind, section, refs, preferred = []) {
|
||
for (const key of preferred) {
|
||
const name = fx(value(kind, section, key));
|
||
if (name) return name;
|
||
}
|
||
for (const index of refs) {
|
||
const name = fx(value("attack", `attribute${index}`, "explosion_effect")) || fx(value("attack", `attribute${index}`, "extra_explosion_effect"));
|
||
if (name) return name;
|
||
}
|
||
return "";
|
||
}
|
||
function iconCanvas(name) {
|
||
const canvas = el("canvas", { class: "hero-skill-icon", width: 40, height: 40, title: name || "No icon" });
|
||
if (!name) return canvas;
|
||
(async () => {
|
||
try {
|
||
await HERO.iconReady;
|
||
if (!canvas.isConnected) return;
|
||
const i = name.indexOf("#");
|
||
const image = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : await UIRes.image(null, null, name);
|
||
if (!image?.bmp || !canvas.isConnected) return;
|
||
const scale = Math.min(40 / image.w, 40 / image.h);
|
||
const w = image.w * scale, h = image.h * scale;
|
||
canvas.getContext("2d").drawImage(image.bmp, image.x, image.y, image.w, image.h, (40 - w) / 2, (40 - h) / 2, w, h);
|
||
} catch (err) { console.warn(`Hero icon ${name}: ${err.message}`); }
|
||
})();
|
||
return canvas;
|
||
}
|
||
function makeAction(label, subtitle, action, iconName = "") {
|
||
const children = iconName ? [iconCanvas(iconName)] : [];
|
||
children.push(el("span", {}, [el("b", { text: label }), el("small", { text: subtitle || "" })]));
|
||
const button = el("button", { class: "hero-action", onclick: (e) => runAction(action, e.currentTarget), title: action.description }, children);
|
||
button.heroAction = action;
|
||
return button;
|
||
}
|
||
function skillSpec(section, label) {
|
||
const title = strText("skill", section, "desc_name") || strText("skill", section, "name");
|
||
const animationKeys = ["pre_delay_animation", "skill_motion", "skill_loop_motion", "loop_animation", "skill_end_attack", "end_animation"];
|
||
const animations = animationKeys.map((key) => ani(value("skill", section, key))).filter(Boolean);
|
||
const refs = attackRefs("skill", section, "attack");
|
||
const effect = actionEffect("skill", section, refs, ["pre_delay_all_effect", "skill_charging_effect", "pre_delay_effect", "skill_map_effect", "range_effect"]);
|
||
const buffs = Array.from({ length: Math.min(12, Number.parseInt(value("skill", section, "owner_buff_cnt"), 10) || 0) }, (_, i) => value("skill", section, `owner_buff${i + 1}`)).filter(Boolean);
|
||
const description = [title, value("skill", section, "desc"), animations.length ? "" : "Passive / no animation", ...animations, ...buffs.map((b) => `buff ${b}`), ...refs.map(attributeSummary)].filter(Boolean).join("\n");
|
||
return { title, action: { label, animations, effect, refs, description } };
|
||
}
|
||
function renderActions() {
|
||
if (HERO.iconSource !== HERO.src) {
|
||
HERO.iconSource = HERO.src;
|
||
HERO.iconReady = UIRes.load(HERO.src).catch((err) => console.warn(`Hero icons: ${err.message}`));
|
||
}
|
||
const weapon = "item1", max = Math.min(12, Number.parseInt(value("item", weapon, "max_combo"), 10) || 0);
|
||
const normal = Array.from({ length: max }, (_, i) => {
|
||
const key = `normal_attack${String(i + 1).padStart(2, "0")}`;
|
||
return { key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) };
|
||
}).filter((step) => step.name);
|
||
const combos = normal.map((_, i) => {
|
||
const steps = normal.slice(0, i + 1);
|
||
const refs = steps.flatMap((s) => s.refs);
|
||
const label = Array(i + 1).fill("D").join(" ");
|
||
return makeAction(label, `${i + 1} hit${i ? "s" : ""}`, {
|
||
label, animations: steps.map((s) => s.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs,
|
||
description: `${steps.map((s) => s.name).join(" → ")}\n${refs.map(attributeSummary).join("\n")}`,
|
||
});
|
||
});
|
||
// The client has many item subclasses with their own attack families. Use
|
||
// the animation-bearing config fields so no subclass needs a hardcoded list.
|
||
const fields = HeroIni.section(doc("item"), weapon)?.fields || [];
|
||
const used = new Set(normal.map((s) => s.key.toLowerCase()));
|
||
const addMove = (label, keys) => {
|
||
const steps = keys.map((key) => ({ key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) })).filter((x) => x.name);
|
||
if (!steps.length) return;
|
||
steps.forEach((x) => used.add(x.key.toLowerCase()));
|
||
const refs = steps.flatMap((x) => x.refs);
|
||
combos.push(makeAction(label, steps.map((x) => x.name).join(" → "), {
|
||
label, animations: steps.map((x) => x.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs,
|
||
description: steps.map((x) => `[${x.key}] ${x.name}`).concat(refs.map(attributeSummary)).join("\n"),
|
||
}));
|
||
};
|
||
const commandIds = [...new Set(fields.map((f) => /^((?:jump_)?cmd_info)(\d+)$/.exec(f.key.toLowerCase())).filter(Boolean).map((m) => `${m[1]}${m[2]}`))];
|
||
for (const id of commandIds) {
|
||
const m = /^((?:jump_)?cmd_info)(\d+)$/.exec(id), base = `${m[1]}${m[2]}`;
|
||
const code = value("item", weapon, base);
|
||
const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" };
|
||
const label = `${m[1].startsWith("jump") ? "Air " : ""}${[...code].map((c) => arrows[c] || c).join("")}`;
|
||
const keys = fields.filter((f) => new RegExp(`^${base}_combo\\d+$`, "i").test(f.key) && ani(f.value)).map((f) => f.key);
|
||
if (keys.length) addMove(label, keys);
|
||
if (!keys.length && value("item", weapon, `${base}_skill_use`) === "1") {
|
||
const slot = Number(value("item", weapon, `${base}_skill_slot`));
|
||
const spec = skillSpec(`skill${slot + 1}`, label);
|
||
combos.push(makeAction(label, spec.title || `Skill slot ${slot + 1}`, spec.action));
|
||
}
|
||
for (const suffix of ["d", "s"]) {
|
||
const branches = fields.filter((f) => new RegExp(`^${base}_combo\\d+_${suffix}$`, "i").test(f.key) && ani(f.value)).map((f) => f.key);
|
||
for (const branch of branches) addMove(`${label} + ${suffix.toUpperCase()}`, [...keys.slice(0, -1), branch]);
|
||
}
|
||
}
|
||
const labels = { dash_attack: "Dash + D", assault_dash_attack: "Assault dash + D", jump_attack: "Air + D", extend_jump_attack: "Extended air + D", land_jump_attack: "Landing + D", counter_attack: "Counter", run_animation: "Run", dash_animation: "Dash", jumpping_animation: "Jump", jump_end_animation: "Land", defense_animation: "Defense" };
|
||
const isSelfMove = (key) => !/(?:_wounded(?:_|$)|^wound|^shop_|^award_|^win_|^lose_|^special_motion|_camera$)/i.test(key);
|
||
for (const f of fields) {
|
||
const key = f.key.toLowerCase();
|
||
if (used.has(key) || !ani(f.value) || !isSelfMove(key)) continue;
|
||
const label = labels[key] || (/half.*attack/i.test(key) ? `${key.startsWith("dash") ? "Dash " : key.startsWith("jump") ? "Air " : ""}Half D · ${key}` : key.replace(/_/g, " "));
|
||
addMove(label, [f.key]);
|
||
}
|
||
$("hero-combos").replaceChildren(...combos);
|
||
const slots = [["SD", "skill1"], ["ASD", "skill2"], ["AD", "skill3"], ["AS", "skill4"]];
|
||
$("hero-skills").replaceChildren(...slots.map(([label, section]) => {
|
||
if (!doc("skill") || !HeroIni.section(doc("skill"), section)) return makeAction(label, "No skill", { label, animations: [], description: "No skill definition" });
|
||
const spec = skillSpec(section, label);
|
||
return makeAction(label, spec.title, spec.action, value("skill", section, "icon") || value("skill", section, "skill_emoticon"));
|
||
}));
|
||
}
|
||
async function runAction(action, button) {
|
||
const token = ++HERO.actionToken;
|
||
const practice = HERO.practice;
|
||
if (practice.active) { practice.busy = true; practice.lastMotion = ""; }
|
||
MDL.clearHeroEffect();
|
||
$("hero-action-label").textContent = action.label;
|
||
$("hero-action-info").textContent = action.description || "No config details for this action.";
|
||
for (const b of document.querySelectorAll(".hero-action")) b.classList.toggle("on", b === button);
|
||
if (!action.animations.length) {
|
||
MDL.stopHeroSequence();
|
||
if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`));
|
||
if (practice.active) { practiceHit(action); practice.busy = false; }
|
||
$("hero-play-status").textContent = "Passive / no animation in config";
|
||
return;
|
||
}
|
||
if (!HERO.previewReady) await buildPreview();
|
||
if (!HERO.previewReady || token !== HERO.actionToken) return;
|
||
$("hero-play-status").textContent = `${action.animations.length} animation step(s)`;
|
||
try {
|
||
if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`));
|
||
let hitDone = false;
|
||
const hit = () => { if (!hitDone && token === HERO.actionToken && practice.active) { hitDone = true; practiceHit(action); } };
|
||
await MDL.playSequence(action.animations, {
|
||
onStep: (name) => { if (token === HERO.actionToken) $("hero-play-status").textContent = `${action.label} · ${name}`; },
|
||
onEvent: (event) => {
|
||
if (token !== HERO.actionToken) return;
|
||
if (/^fire_(?:s|e|sliding)$/i.test(event.type.text)) hit();
|
||
if (event.type.text.toLowerCase() !== "auto_effect") return;
|
||
const name = fx(event.sub.text);
|
||
if (name) MDL.playHeroEffect(HERO.src, name).catch((err) => console.warn(`Animation effect: ${err.message}`));
|
||
},
|
||
onDone: () => { if (token === HERO.actionToken) { hit(); $("hero-play-status").textContent = `${action.label} complete`; if (practice.active) { practice.busy = false; practice.lastMotion = ""; updatePracticeMotion(); } else MDL.playNamed(value("item", "item1", "delay_animation")); } },
|
||
});
|
||
if (practice.active) setTimeout(hit, 400);
|
||
} catch (err) { practice.busy = false; toast(err.message, true); $("hero-play-status").textContent = err.message; }
|
||
}
|
||
const practiceButtons = () => [...document.querySelectorAll("#hero-combos .hero-action")];
|
||
const actionLabel = (button) => button?.querySelector("b")?.textContent || "";
|
||
const actionButton = (label) => practiceButtons().find((button) => actionLabel(button) === label);
|
||
const practiceVisible = () => HERO.practice.active && HERO.id && $("view-hero").classList.contains("on");
|
||
function practiceHit(action) {
|
||
const p = HERO.practice;
|
||
if (!p.active || (!action.refs?.length && !action.effectTarget)) return;
|
||
const dx = p.target[0] - p.pos[0], dz = p.target[2] - p.pos[2];
|
||
const distance = Math.hypot(dx, dz);
|
||
const facing = (Math.sin(p.yaw * Math.PI / 180) * dx + Math.cos(p.yaw * Math.PI / 180) * dz) / (distance || 1);
|
||
if (distance > 155 || facing < 0.2) { $("hero-play-status").textContent = `${action.label} · miss`; return; }
|
||
const rates = (action.refs || []).map((ref) => Number.parseFloat(value("attack", `attribute${ref}`, "damage_rate")) || 1);
|
||
const damage = Math.max(5, Math.min(35, Math.round(rates.reduce((sum, rate) => sum + rate, 0) * 2)));
|
||
p.hp = Math.max(0, p.hp - damage);
|
||
$("hero-dummy-health").textContent = `Dummy ${p.hp} / 100 · −${damage}`;
|
||
$("hero-play-status").textContent = `${action.label} · hit ${damage}`;
|
||
if (action.effectTarget && action.effect) MDL.playHeroEffect(HERO.src, action.effect, { target: true }).catch((err) => console.warn(`Practice hit effect: ${err.message}`));
|
||
if (p.hp === 0) setTimeout(() => { if (p.active && p.hp === 0) { p.hp = 100; $("hero-dummy-health").textContent = "Dummy 100 / 100"; } }, 1800);
|
||
}
|
||
function updatePracticeMotion() {
|
||
const p = HERO.practice;
|
||
if (!practiceVisible() || !HERO.previewReady || p.busy) return;
|
||
const moving = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].some((key) => p.keys.has(key));
|
||
const dashing = p.keys.has("Shift") || performance.now() < p.dashUntil;
|
||
const key = !p.grounded ? "jumpping_animation" : p.keys.has("s") ? "defense_animation" : moving ? dashing ? "dash_animation" : "run_animation" : "delay_animation";
|
||
const name = ani(value("item", "item1", key)) || ani(value("item", "item1", "delay_animation"));
|
||
if (!name || name === p.lastMotion) return;
|
||
p.lastMotion = name;
|
||
MDL.playHeroMotion(name).catch((err) => console.warn(`Practice motion ${name}: ${err.message}`));
|
||
}
|
||
async function setPractice(on) {
|
||
const p = HERO.practice;
|
||
if (on && !HERO.previewReady) await buildPreview();
|
||
if (on && !HERO.previewReady) return;
|
||
p.active = !!on;
|
||
p.keys.clear(); p.directions = []; p.combo = 0; p.busy = false; p.chord = false;
|
||
p.pos = [0, 0, 0]; p.yaw = 0; p.vy = 0; p.grounded = true; p.dashUntil = 0; p.hp = 100; p.lastMotion = "";
|
||
clearTimeout(p.attackTimer);
|
||
$("hero-practice").classList.toggle("on", p.active);
|
||
$("hero-practice").textContent = p.active ? "Exit training" : "Training mode";
|
||
$("hero-practice-hud").hidden = !p.active;
|
||
$("hero-practice-help").hidden = !p.active;
|
||
$("hero-dummy-health").textContent = "Dummy 100 / 100";
|
||
MDL.clearHeroEffect();
|
||
MDL.stopHeroSequence();
|
||
MDL.setHeroTransform(p.pos, p.yaw);
|
||
MDL.setHeroPracticeTarget(p.active ? p.target : null);
|
||
MDL.setPracticeFloor?.(p.active ? PRACTICE_ARENA : null);
|
||
if (p.active) {
|
||
$("hero-stage").focus({ preventScroll: true });
|
||
if (MDL.view) { MDL.view.cam.target = [0, 75, -110]; MDL.view.cam.dist = Math.max(450, MDL.view.cam.dist); MDL.view.dirty = true; }
|
||
$("hero-play-status").textContent = "Training ready · move toward the dummy";
|
||
updatePracticeMotion();
|
||
} else {
|
||
MDL.view?.reset();
|
||
$("hero-play-status").textContent = "Training stopped";
|
||
}
|
||
}
|
||
$("hero-practice").onclick = () => setPractice(!HERO.practice.active);
|
||
|
||
function commandButton() {
|
||
const p = HERO.practice, now = performance.now();
|
||
const maxTime = Number.parseInt(value("item", "item1", "input_max_time"), 10) || 800;
|
||
p.directions = p.directions.filter((x) => now - x.time <= maxTime);
|
||
const input = p.directions.map((x) => x.code).join("");
|
||
const fields = HeroIni.section(doc("item"), "item1")?.fields || [];
|
||
const commands = fields.filter((f) => /^(?:jump_)?cmd_info\d+$/i.test(f.key) && /^\d+$/.test(f.value))
|
||
.filter((f) => f.key.startsWith("jump_") === !p.grounded && input.endsWith(f.value))
|
||
.sort((a, b) => b.value.length - a.value.length);
|
||
const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" };
|
||
for (const cmd of commands) {
|
||
const label = `${cmd.key.startsWith("jump_") ? "Air " : ""}${[...cmd.value].map((c) => arrows[c]).join("")}`;
|
||
const button = actionButton(label);
|
||
if (button) { p.directions = []; return button; }
|
||
}
|
||
return null;
|
||
}
|
||
function practiceAttack(held = 0) {
|
||
const p = HERO.practice;
|
||
if (!practiceVisible() || !HERO.previewReady) return;
|
||
let button = null;
|
||
if (held >= Math.max(250, Number.parseInt(value("item", "item1", "half_charge_time"), 10) || Number.parseInt(value("item", "item1", "half_charging_time"), 10) || 450))
|
||
button = practiceButtons().find((b) => /^Half D\b/.test(actionLabel(b)));
|
||
if (!button) button = commandButton();
|
||
if (!button && !p.grounded) button = practiceButtons().find((b) => /^(Air \+ D|jump attack\d+|Extended air \+ D)/i.test(actionLabel(b)));
|
||
if (!button && (p.keys.has("Shift") || performance.now() < p.dashUntil)) button = actionButton("Assault dash + D") || actionButton("Dash + D");
|
||
if (button) { p.combo = 0; runAction(button.heroAction, button); return; }
|
||
const max = Math.min(12, Number.parseInt(value("item", "item1", "max_combo"), 10) || 1);
|
||
p.combo = performance.now() - p.comboAt < 850 ? p.combo % max + 1 : 1;
|
||
p.comboAt = performance.now();
|
||
button = actionButton(Array(p.combo).fill("D").join(" ")) || actionButton("D");
|
||
if (!button) return;
|
||
const key = `normal_attack${String(p.combo).padStart(2, "0")}`;
|
||
const name = ani(value("item", "item1", key));
|
||
const refs = attackRefs("item", "item1", key);
|
||
const action = { label: actionLabel(button), animations: name ? [name] : button.heroAction.animations,
|
||
effect: actionEffect("item", "item1", refs), effectTarget: true, refs,
|
||
description: `[${key}] ${name}\n${refs.map(attributeSummary).join("\n")}` };
|
||
runAction(action, button);
|
||
}
|
||
function practiceSkill(slot) {
|
||
const button = [...document.querySelectorAll("#hero-skills .hero-action")][slot];
|
||
if (button) { HERO.practice.combo = 0; runAction(button.heroAction, button); }
|
||
}
|
||
const practiceKey = (event) => event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||
document.addEventListener("keydown", (event) => {
|
||
if (!practiceVisible() || event.target.matches?.("input, textarea, select, [contenteditable]") || event.ctrlKey || event.metaKey || event.altKey) return;
|
||
const p = HERO.practice, key = practiceKey(event), now = performance.now();
|
||
if (key === "Escape") { event.preventDefault(); setPractice(false); return; }
|
||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d", "1", "2", "3", "4"].includes(key)) return;
|
||
event.preventDefault();
|
||
const wasDown = p.keys.has(key);
|
||
p.keys.add(key);
|
||
if (/^Arrow/.test(key)) {
|
||
if (!wasDown) {
|
||
const codes = { ArrowUp: "1", ArrowRight: "2", ArrowDown: "3", ArrowLeft: "4" };
|
||
p.directions.push({ code: codes[key], time: now });
|
||
p.directions = p.directions.slice(-12);
|
||
if (now - (p.lastTap[key] || 0) < 280) p.dashUntil = now + 500;
|
||
p.lastTap[key] = now;
|
||
}
|
||
updatePracticeMotion();
|
||
return;
|
||
}
|
||
if (key === "Shift") { updatePracticeMotion(); return; }
|
||
if (wasDown) return;
|
||
if (/^[1-4]$/.test(key)) { practiceSkill(Number(key) - 1); return; }
|
||
const a = p.keys.has("a"), s = p.keys.has("s"), d = p.keys.has("d");
|
||
const slot = a && s && d ? 1 : s && d ? 0 : a && d ? 2 : a && s ? 3 : -1;
|
||
if (slot >= 0 && !p.chord) {
|
||
p.chord = true; p.attackPending = false; clearTimeout(p.attackTimer);
|
||
practiceSkill(slot);
|
||
return;
|
||
}
|
||
if (key === "a" && p.grounded) {
|
||
p.grounded = false;
|
||
p.vy = 275 * (Number.parseFloat(value("item", "item1", "jump_power_rate")) || 1);
|
||
p.lastMotion = "";
|
||
updatePracticeMotion();
|
||
} else if (key === "s") updatePracticeMotion();
|
||
else if (key === "d") {
|
||
p.attackDownAt = now; p.attackPending = true;
|
||
const hasHalf = practiceButtons().some((button) => /^Half D\b/.test(actionLabel(button)));
|
||
if (!hasHalf) p.attackTimer = setTimeout(() => {
|
||
if (p.attackPending && p.keys.has("d") && !p.chord) { p.attackPending = false; practiceAttack(); }
|
||
}, 75);
|
||
}
|
||
});
|
||
document.addEventListener("keyup", (event) => {
|
||
if (!HERO.practice.active) return;
|
||
const p = HERO.practice, key = practiceKey(event);
|
||
if (!p.keys.has(key)) return;
|
||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d"].includes(key)) event.preventDefault();
|
||
p.keys.delete(key);
|
||
if (key === "d") {
|
||
clearTimeout(p.attackTimer);
|
||
if (p.attackPending && !p.chord) practiceAttack(performance.now() - p.attackDownAt);
|
||
p.attackPending = false;
|
||
}
|
||
if (!["a", "s", "d"].some((k) => p.keys.has(k))) p.chord = false;
|
||
if (/^Arrow/.test(key) || key === "Shift" || key === "s") updatePracticeMotion();
|
||
});
|
||
window.addEventListener("blur", () => { if (HERO.practice.active) { HERO.practice.keys.clear(); HERO.practice.attackPending = false; updatePracticeMotion(); } });
|
||
let practiceLastFrame = 0;
|
||
const PRACTICE_ARENA = 450; // half size of the training floor, game units
|
||
function practiceTick(now) {
|
||
const dt = Math.min(0.05, Math.max(0, (now - (practiceLastFrame || now)) / 1000));
|
||
practiceLastFrame = now;
|
||
const p = HERO.practice;
|
||
if (practiceVisible() && HERO.previewReady) {
|
||
const dx = Number(p.keys.has("ArrowRight")) - Number(p.keys.has("ArrowLeft"));
|
||
const dz = Number(p.keys.has("ArrowUp")) - Number(p.keys.has("ArrowDown"));
|
||
const length = Math.hypot(dx, dz) || 1;
|
||
const moving = !!(dx || dz);
|
||
const dashing = p.keys.has("Shift") || now < p.dashUntil;
|
||
const dashRate = Number.parseFloat(value("item", "item1", "dash_run_max_speed_rate")) || 1;
|
||
const speed = (dashing ? 240 * dashRate : 125) * (p.busy ? 0.35 : 1);
|
||
if (moving) {
|
||
const edge = PRACTICE_ARENA - 20; // keep the whole body on the floor grid
|
||
p.pos[0] = Math.max(-edge, Math.min(edge, p.pos[0] + dx / length * speed * dt));
|
||
p.pos[2] = Math.max(-edge, Math.min(edge, p.pos[2] + dz / length * speed * dt));
|
||
p.yaw = Math.atan2(dx, dz) * 180 / Math.PI;
|
||
}
|
||
if (!p.grounded) {
|
||
p.vy -= 780 * dt;
|
||
p.pos[1] += p.vy * dt;
|
||
if (p.pos[1] <= 0) { p.pos[1] = 0; p.vy = 0; p.grounded = true; p.lastMotion = ""; updatePracticeMotion(); }
|
||
}
|
||
if (moving || !p.grounded) MDL.setHeroTransform(p.pos, p.yaw);
|
||
// Camera follows the hero (looking a little toward the dummy) and backs off when the two are far
|
||
// apart, so the hero never walks out of the frame. It never zooms in: a manual zoom-out stays.
|
||
// The view's z axis is the game's z flipped (see the start target [0, 75, -110]).
|
||
if (MDL.view) {
|
||
const cam = MDL.view.cam, k = Math.min(1, 4 * dt);
|
||
const fx = p.pos[0] + (p.target[0] - p.pos[0]) * 0.35, fz = p.pos[2] + (p.target[2] - p.pos[2]) * 0.35;
|
||
const need = Math.min(1000, 380 + Math.hypot(p.target[0] - p.pos[0], p.target[2] - p.pos[2]) * 0.9);
|
||
const before = cam.target[0] + cam.target[2] + cam.dist;
|
||
cam.target[0] += (fx - cam.target[0]) * k;
|
||
cam.target[2] += (-fz - cam.target[2]) * k;
|
||
if (cam.dist < need) cam.dist += (need - cam.dist) * k;
|
||
if (Math.abs(cam.target[0] + cam.target[2] + cam.dist - before) > 0.01) MDL.view.dirty = true;
|
||
}
|
||
updatePracticeMotion();
|
||
}
|
||
requestAnimationFrame(practiceTick);
|
||
}
|
||
requestAnimationFrame(practiceTick);
|
||
$("hero-idle").onclick = async () => {
|
||
if (HERO.practice.active) await setPractice(false);
|
||
HERO.actionToken++;
|
||
MDL.stopHeroSequence();
|
||
const name = value("item", "item1", "delay_animation");
|
||
if (name) await MDL.playNamed(name);
|
||
$("hero-action-label").textContent = "Idle";
|
||
$("hero-play-status").textContent = name || "Idle pose";
|
||
};
|
||
$("hero-stop").onclick = () => { if (HERO.practice.active) setPractice(false); HERO.actionToken++; MDL.stopHeroSequence(); $("hero-play-status").textContent = "Stopped"; };
|
||
$("hero-export").onclick = async () => {
|
||
const files = dirty().map((kind) => ({ kind, text: HERO.docs[kind].text }));
|
||
if (!files.length) return;
|
||
const folder = await call("dialog:folder", "Choose a folder for the exported hero configs");
|
||
if (!folder) return;
|
||
try {
|
||
const out = await call("hero:export", folder, HERO.id, files);
|
||
for (const f of files) HERO.docs[f.kind].saved = f.text;
|
||
updateStatus();
|
||
$("hero-export-status").textContent = `Exported to ${out.folder}`;
|
||
toast(`Exported ${out.files.length} config file(s).`);
|
||
} catch (err) { toast(err.message, true); }
|
||
};
|
||
document.querySelector('[data-view="hero"]').addEventListener("click", async () => {
|
||
if (HERO.src) {
|
||
if (HERO.id && MDL.scene?.model?.name === "Hero preview") MDL.embedHero(true);
|
||
else if (HERO.id) buildPreview();
|
||
return;
|
||
}
|
||
const src = profileSource();
|
||
if (src) try { await setSource(src); } catch (err) { toast(err.message, true); }
|
||
});
|
||
})();
|