"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_::
_) 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); } }); })();