Files

848 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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); }
});
})();