feedback6: merge R21F — enemy flyers hover 1.3 tiles above the road instead of climbing each raised tile (GitHub #277, @FrogThai); enemy animation states from PR #275 by @xcdoge, client part: numbered skill clips, the Stun_1 / Dizzy_* spellings, run cycles (猎狗pro, 深池侦察犬), the rebirths of 巨大的丑东西 and 自在 (data/assets.json regenerated offline: enemies only); golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 18:03:47 +08:00
commit 1e58843fb4
13 files changed
+396 -30

No files matched your search

+42 -7
View File
@@ -28,17 +28,21 @@
// loop = P_Loop → P_Attack… → P → P_Idle → P_Begin; begin = P_Begin|P_Start; end = P_End;
// idle = P_Idle (replaces idle while the skill is active);
// else the same over directional-only clips (Skill_Right_Loop, Skill_Loop_Up; Right before Up);
// else attack (via attack)
// else attack (via attack); with `numberedSkills` (enemies) every numbered skill clip of the skeleton
// (Skill_01, Skill_2_Loop …) adds its index after the caller's, giving `skills` (a multi-skill boss)
// die : Die → <form>Die → first /^Die/ → first /_Die$/ → null (renderer: Front model's Die, else fade)
// move : Move_Begin|Move_Start + (Move_Loop → Move) + Move_End → <form>Move → Run_Begin/Loop/End
// → Run → first /^Move/ → null
// stun : Stun_Begin + Stun (+ Stun_End) → Stun → null (renderer: freeze the track)
// run : Run_Begin/Loop/End → Run → <form>Run → absent (only a model with its own Run cycle has the role)
// stun : (Stun_Begin | Dizzy_Begin) + (Stun → Stun_1 → Dizzy_Loop) (+ Stun_End | Dizzy_End) → Stun_Begin alone
// → null (renderer: freeze the track)
/**
* @typedef {{ begin: string|null, loop: string, end: string|null, via?: string }} Clip
* @typedef {Clip & { index: number, idle: string|null }} SkillClip
* @typedef {{ idle: string|null, deploy: string|null, attack: Clip|null, attackDown: Clip|null,
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, stun: Clip|null }} Roles
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, run?: Clip,
* stun: Clip|null }} Roles
*/
function makeFinder(names) {
@@ -221,10 +225,27 @@ function resolveMove(names, find, form) {
return any ? clip(null, any, null) : null;
}
/**
* The model's own run cycle, when it has one (猎狗pro: Move_Loop 0.80 s next to Run_Loop 0.53 s): a role of its own, not
* the move choice — the renderer walks a fast enemy on it (render/units.js `moveFast`; PR #275 by @xcdoge).
*/
function resolveRun(find, form) {
const t = triple(find, 'Run');
if (t) return t;
const r = find('Run') ?? form('Run');
return r ? clip(null, r, null) : null;
}
/**
* The stun family is spelled two ways and sometimes numbered (PR #275 by @xcdoge): Stun / Stun_Begin / Stun_End
* (吉兆飞鳞, 乌顶巨角卢鲁), Stun_1 / Stun_2 (巨大的丑东西: its first and its second form — render/units.js FORMS gives the
* second form Stun_2), Dizzy_Begin / Dizzy_Loop / Dizzy_End (斩胄之剑 / 破胄之锤, stun-immune in this mode). Without a role
* a stunned enemy freezes its current clip.
*/
function resolveStun(find) {
const stun = find('Stun');
const begin = find('Stun_Begin');
if (stun) return clip(begin, stun, find('Stun_End'));
const stun = find('Stun') ?? find('Stun_1') ?? find('Dizzy_Loop');
const begin = find('Stun_Begin') ?? find('Dizzy_Begin');
if (stun) return clip(begin, stun, find('Stun_End') ?? find('Dizzy_End'));
if (begin) return clip(null, begin, null);
return null;
}
@@ -241,6 +262,8 @@ function resolveDie(names, find, form) {
* @param {string[]} animationNames all animation names in the skeleton
* @param {object} [opts]
* @param {number[]} [opts.skillIndices] 0-based skill indices to resolve (first = primary); default [0]
* @param {boolean} [opts.numberedSkills] also resolve every numbered skill clip of the skeleton (Skill_01, Skill_2_Loop …)
* after the caller's indices: an enemy's per-skill clips (a multi-skill boss such as 盐风主教昆图斯, Skill_01..04)
* @param {Record<string, number>} [opts.durations] animation durations in seconds (prefers animated idles)
* @returns {Roles}
*/
@@ -256,6 +279,16 @@ export function resolveRoles(animationNames, opts = {}) {
const indices = [...new Set((opts.skillIndices?.length ? opts.skillIndices : [0])
.filter((i) => Number.isInteger(i) && i >= 0 && i < 10))];
if (!indices.length) indices.push(0);
// an enemy's numbered skill clips (PR #275 by @xcdoge: its manifest only ever had index 0, so a multi-skill boss could
// show one cast clip): appended after the caller's indices, whose first stays the primary `skill`
if (opts.numberedSkills) {
const found = new Set();
for (const n of names) {
const m = /^Skill_?0*(\d+)(?:$|_)/i.exec(n);
if (m && Number(m[1]) >= 1 && Number(m[1]) <= 10) found.add(Number(m[1]) - 1);
}
for (const i of [...found].sort((a, b) => a - b)) if (!indices.includes(i)) indices.push(i);
}
/** @type {Record<string, SkillClip>} */
const skills = {};
for (const i of indices) {
@@ -272,6 +305,8 @@ export function resolveRoles(animationNames, opts = {}) {
move: resolveMove(names, find, form),
stun: resolveStun(find),
};
const run = resolveRun(find, form);
if (run) roles.run = run;
if (indices.length > 1) roles.skills = skills;
return roles;
}
@@ -289,7 +324,7 @@ export function roleAnimationNames(roles) {
};
if (!roles) return [];
for (const k of ['idle', 'deploy', 'die']) if (typeof roles[k] === 'string') out.add(roles[k]);
for (const k of ['attack', 'attackDown', 'skill', 'move', 'stun']) addClip(roles[k]);
for (const k of ['attack', 'attackDown', 'skill', 'move', 'run', 'stun']) addClip(roles[k]);
if (roles.skills) for (const c of Object.values(roles.skills)) addClip(c);
return [...out];
}
+6 -3
View File
@@ -166,7 +166,8 @@ export function applyRoleFix(roles, fix, durations) {
/**
* Spine metadata of extracted models (findLocalEnemyModels / findLocalTokenModels), parsed like processModels does — but
* read only: the atlas is sized / pma-normalized in memory (extract.py already writes it so), nothing on disk changes.
* Roles are resolved for skill index 0, like the web enemy and token models, then LOCAL_SPINE_ROLES applied.
* Roles are resolved for skill index 0 (an enemy also for its numbered skill clips), like the web enemy and token models,
* then LOCAL_SPINE_ROLES applied.
* @param {string} root absolute public/assets directory
* @param {Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>} found
* @returns {Promise<{ meta: Record<string, LocalSpineMeta>, problems: string[] }>}
@@ -192,7 +193,7 @@ export async function localSpineMeta(root, found) {
const sk = parseSkel(await readFile(join(root, m.skel)), info.regions);
if (!sk.animations.length) throw new Error('skeleton has no animations');
if (sk.missingRegions?.length) problems.push(`${id}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], numberedSkills: id.startsWith('enemy_'), durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
if (fixed.missing.length) problems.push(`${id}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
meta[id] = {
skel: base(m.skel), atlas: base(m.atlas), textures: info.pages, pma: true,
@@ -330,7 +331,9 @@ export async function processModels(models, { root, dl, cachePath, download = tr
if (!sk.animations.length) { problems.push(`${m.key}: skeleton has no animations`); continue; }
const key = String(m.key);
const id = key.startsWith('enemy:') ? key.slice('enemy:'.length) : key.startsWith('op:') ? key.split(':')[1] : null; // op:<char>:front|back
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: m.skillIndices, durations: sk.durations }), id ? PREFAB_SPINE_ROLES[id] : undefined, sk.durations);
// an enemy's numbered skill clips are resolved too (anims.skills: the clip of each skill slot it casts, PR #275);
// operators keep the pool's indices (their equipped skill picks among them)
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: m.skillIndices, numberedSkills: key.startsWith('enemy:'), durations: sk.durations }), id ? PREFAB_SPINE_ROLES[id] : undefined, sk.durations);
if (fixed.missing.length) problems.push(`${m.key}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
const anims = fixed.roles;
entries.set(m.key, {