@roost2d/chikn-rigs 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/actions.js CHANGED
@@ -1,39 +1,65 @@
1
- const BLADE = /(?:katana|sword|cutlass|broadsword|snip|spork|shovel|9-iron|the-key|scorpion-king)/;
2
- const BLUNT = /(?:stick|corn|baguette|batter-up|home-run|banjo|bone|fish|popsicle|nugget|rubber-chikn|whalegod)/;
3
- const RANGED = /(?:peacemaker|banger|zippo|boombox|floppy-disk|maverick|admiral)/;
4
- const CASTING = /(?:wand|mage|all-seeing-eye|super-saiyan|saint|hypno|jell-o|beaker)/;
5
- const TAIL_ATTACK = /(?:scorpion-king|sword-tail|hydra)/;
1
+ import { CURATED_TRAIT_PROFILES, curatedTraitProfile, } from './trait-profiles.js';
2
+ const DEFAULT_TARGET = Object.freeze({ x: 180, y: 0 });
6
3
  const profileCache = new WeakMap();
7
4
  const clipCache = new WeakMap();
8
- /** Returns one immutable motion profile for every attachment group in a rig. */
5
+ const MOTION_ADJUSTMENTS = {
6
+ rigid: { amplitude: .72, speed: 1.08 },
7
+ soft: { amplitude: 1, speed: .96 },
8
+ dangling: { amplitude: 1.16, speed: .92 },
9
+ cloth: { amplitude: 1.12, speed: .9 },
10
+ feather: { amplitude: 1.06, speed: 1 },
11
+ bulky: { amplitude: .82, speed: .88 },
12
+ elastic: { amplitude: 1.22, speed: .94 },
13
+ };
14
+ const PRESET_CALIBRATION = {
15
+ katana: { grip: { x: -8, y: 4 }, tip: { x: 38, y: -2 }, origin: { x: 30, y: -4 }, timing: { anticipationMs: 90, releaseMs: 185, recoveryMs: 290 } },
16
+ sword: { grip: { x: -7, y: 5 }, tip: { x: 34, y: -4 }, origin: { x: 28, y: -3 }, timing: { anticipationMs: 135, releaseMs: 255, recoveryMs: 390 } },
17
+ snips: { grip: { x: -5, y: 2 }, tip: { x: 25, y: 0 }, origin: { x: 22, y: 0 }, timing: { anticipationMs: 120, releaseMs: 240, recoveryMs: 360 } },
18
+ thrust: { grip: { x: -8, y: 5 }, tip: { x: 36, y: 0 }, origin: { x: 32, y: 0 }, timing: { anticipationMs: 150, releaseMs: 275, recoveryMs: 390 } },
19
+ golf: { grip: { x: -8, y: 6 }, tip: { x: 42, y: 8 }, origin: { x: 38, y: 8 }, timing: { anticipationMs: 190, releaseMs: 350, recoveryMs: 470 } },
20
+ pickaxe: { grip: { x: -7, y: 5 }, tip: { x: 34, y: 10 }, origin: { x: 30, y: 8 }, timing: { anticipationMs: 180, releaseMs: 330, recoveryMs: 470 } },
21
+ gun: { grip: { x: -8, y: 7 }, tip: { x: 34, y: -3 }, origin: { x: 31, y: -3 }, timing: { anticipationMs: 120, releaseMs: 220, recoveryMs: 330 } },
22
+ egg: { grip: { x: 0, y: 0 }, tip: { x: 0, y: 0 }, origin: { x: 0, y: 0 }, timing: { anticipationMs: 160, releaseMs: 400, recoveryMs: 800 } },
23
+ laser: { grip: { x: 0, y: 0 }, tip: { x: 0, y: 0 }, origin: { x: 0, y: 0 }, timing: { anticipationMs: 170, releaseMs: 280, recoveryMs: 440 } },
24
+ disk: { grip: { x: -5, y: 2 }, tip: { x: 18, y: 0 }, origin: { x: 10, y: -2 }, timing: { anticipationMs: 180, releaseMs: 320, recoveryMs: 590 } },
25
+ pan: { grip: { x: -7, y: 4 }, tip: { x: 22, y: 2 }, origin: { x: 13, y: -1 }, timing: { anticipationMs: 190, releaseMs: 330, recoveryMs: 600 } },
26
+ sonic: { grip: { x: -4, y: 4 }, tip: { x: 24, y: 0 }, origin: { x: 19, y: -2 }, timing: { anticipationMs: 170, releaseMs: 300, recoveryMs: 500 } },
27
+ flame: { grip: { x: -4, y: 4 }, tip: { x: 22, y: 0 }, origin: { x: 18, y: 0 }, timing: { anticipationMs: 160, releaseMs: 290, recoveryMs: 490 } },
28
+ exhaust: { grip: { x: 0, y: 0 }, tip: { x: -18, y: 2 }, origin: { x: -16, y: 2 }, timing: { anticipationMs: 120, releaseMs: 260, recoveryMs: 430 } },
29
+ };
30
+ /** Returns one checked-in, immutable motion profile for every attachment group in a rig. */
9
31
  export function createChiknTraitAnimationProfiles(definition) {
10
32
  const cached = profileCache.get(definition);
11
33
  if (cached)
12
34
  return cached;
13
- const species = definition.id;
14
- if (species !== 'chikn' && species !== 'roostr')
15
- throw new Error(`Unsupported Chikn rig species: ${species}`);
35
+ const species = speciesOf(definition);
16
36
  const profiles = Object.freeze(Object.values(definition.attachmentGroups ?? {}).map((group) => {
37
+ const curated = curatedTraitProfile(species, group.id);
38
+ if (!curated)
39
+ throw new Error(`Trait group has no curated animation profile: ${species}:${group.id}`);
17
40
  const category = String(group.metadata?.category ?? group.slotId);
18
- const token = group.id.toLowerCase();
19
- const motionFamily = category.toLowerCase() === 'feet' ? 'footwear'
20
- : category.toLowerCase() === 'tail' && TAIL_ATTACK.test(token) ? 'tail'
21
- : BLADE.test(token) ? 'blade'
22
- : RANGED.test(token) ? 'ranged'
23
- : CASTING.test(token) ? 'casting'
24
- : BLUNT.test(token) ? 'blunt'
25
- : 'accent';
26
- const variation = stableVariation(group.id);
27
- const special = specialFor(group.id);
41
+ const adjustment = MOTION_ADJUSTMENTS[curated.secondaryMotion];
42
+ const preset = curated.specialPreset ?? curated.punchPreset ?? curated.kickPreset;
43
+ const calibration = preset ? PRESET_CALIBRATION[preset] : undefined;
44
+ const special = curated.specialPreset
45
+ ? specialFor(curated.specialPreset, group.id, group.attachmentIds[0], category, calibration?.origin ?? { x: 0, y: 0 })
46
+ : undefined;
28
47
  return Object.freeze({
29
48
  species,
30
49
  traitGroupId: group.id,
31
50
  category,
32
- motionFamily,
51
+ motionFamily: curated.motionFamily,
33
52
  attachmentTargets: Object.freeze([...group.attachmentIds]),
34
53
  supportedActions: Object.freeze(['punch', 'kick', ...(special ? ['special'] : [])]),
35
- amplitude: Number((0.88 + variation * 0.24).toFixed(3)),
36
- speed: Number((0.92 + (1 - variation) * 0.16).toFixed(3)),
54
+ secondaryMotion: curated.secondaryMotion,
55
+ amplitude: adjustment.amplitude,
56
+ speed: adjustment.speed,
57
+ gripOffset: calibration?.grip ?? { x: 0, y: 0 },
58
+ tipOffset: calibration?.tip ?? { x: 0, y: 0 },
59
+ originOffset: calibration?.origin ?? { x: 0, y: 0 },
60
+ timing: calibration?.timing ?? { anticipationMs: 140, releaseMs: 260, recoveryMs: 420 },
61
+ ...(curated.punchPreset ? { punchPreset: curated.punchPreset } : {}),
62
+ ...(curated.kickPreset ? { kickPreset: curated.kickPreset } : {}),
37
63
  ...(special ? { special } : {}),
38
64
  });
39
65
  }));
@@ -42,8 +68,10 @@ export function createChiknTraitAnimationProfiles(definition) {
42
68
  }
43
69
  export function validateChiknTraitAnimationProfiles(definition, profiles) {
44
70
  const errors = [];
71
+ const species = definition.id === 'chikn' || definition.id === 'roostr' ? definition.id : undefined;
45
72
  const groups = definition.attachmentGroups ?? {};
46
73
  const seen = new Set();
74
+ const attachmentIds = new Set(definition.attachments.map(({ id }) => id));
47
75
  const socketIds = new Set((definition.sockets ?? []).map(({ id }) => id));
48
76
  for (const profile of profiles) {
49
77
  if (seen.has(profile.traitGroupId))
@@ -58,16 +86,28 @@ export function validateChiknTraitAnimationProfiles(definition, profiles) {
58
86
  errors.push(`${profile.traitGroupId}: species does not match ${definition.id}`);
59
87
  if (profile.attachmentTargets.join('|') !== group.attachmentIds.join('|'))
60
88
  errors.push(`${profile.traitGroupId}: attachment targets do not match the rig group`);
89
+ if (profile.attachmentTargets.some((id) => !attachmentIds.has(id)))
90
+ errors.push(`${profile.traitGroupId}: unknown driver attachment`);
61
91
  if (!profile.supportedActions.includes('punch') || !profile.supportedActions.includes('kick') || profile.supportedActions.includes('special') !== Boolean(profile.special))
62
92
  errors.push(`${profile.traitGroupId}: supported actions do not match its capabilities`);
63
93
  if (!(profile.amplitude > 0) || !(profile.speed > 0))
64
94
  errors.push(`${profile.traitGroupId}: adjustments must be positive`);
65
- if (profile.special && !socketIds.has(profile.special.socketId))
66
- errors.push(`${profile.traitGroupId}: unknown special socket ${profile.special.socketId}`);
95
+ const numbers = [profile.gripOffset.x, profile.gripOffset.y, profile.tipOffset.x, profile.tipOffset.y, profile.originOffset.x, profile.originOffset.y, profile.timing.anticipationMs, profile.timing.releaseMs, profile.timing.recoveryMs];
96
+ if (numbers.some((value) => !Number.isFinite(value)))
97
+ errors.push(`${profile.traitGroupId}: calibration values must be finite`);
98
+ if (profile.special?.origin.target === 'attachment' && !attachmentIds.has(profile.special.origin.targetId))
99
+ errors.push(`${profile.traitGroupId}: unknown special attachment ${profile.special.origin.targetId}`);
100
+ if (profile.special?.origin.target === 'socket' && !socketIds.has(profile.special.origin.targetId))
101
+ errors.push(`${profile.traitGroupId}: unknown special socket ${profile.special.origin.targetId}`);
67
102
  }
68
103
  for (const groupId of Object.keys(groups))
69
104
  if (!seen.has(groupId))
70
105
  errors.push(`trait group has no animation profile ${groupId}`);
106
+ if (species)
107
+ for (const curated of CURATED_TRAIT_PROFILES.values()) {
108
+ if (curated.species === species && !Object.hasOwn(groups, curated.traitGroupId))
109
+ errors.push(`curated profile references unknown ${species} trait group ${curated.traitGroupId}`);
110
+ }
71
111
  return errors;
72
112
  }
73
113
  export function listChiknSpecials(recipe, definition) {
@@ -85,38 +125,57 @@ export function listAvailableChiknActions(recipe, definition) {
85
125
  ...listChiknSpecials(recipe, definition),
86
126
  ];
87
127
  }
88
- export function resolveChiknAction(recipe, definition, actionId) {
128
+ export function resolveChiknAction(recipe, definition, actionId, context = {}) {
89
129
  assertRecipe(recipe, definition);
130
+ const targetOffset = normaliseTarget(context.targetOffset);
90
131
  const clips = new Map(createChiknActionClips(definition).map((clip) => [clip.id, clip]));
91
132
  const profiles = profileMap(definition);
92
133
  const selected = recipe.traitGroupIds.map((id) => profiles.get(id)).filter(Boolean);
93
134
  if (actionId === 'punch') {
94
- const weapon = selected.find(({ motionFamily }) => ['blade', 'ranged', 'casting', 'blunt'].includes(motionFamily));
135
+ const weapon = selected.find(({ punchPreset }) => punchPreset !== undefined);
95
136
  const family = weapon?.motionFamily ?? 'unarmed';
96
- const clip = tailorActionClip(definition, requiredClip(clips, `${definition.id}.action.punch.${family}`), selected, weapon);
97
- const effectKind = family === 'blade' ? 'slash' : family === 'ranged' ? 'projectile' : family === 'casting' ? 'burst' : undefined;
137
+ const base = requiredClip(clips, `${definition.id}.action.punch.${family}`);
138
+ const clip = tailorActionClip(definition, base, selected, weapon);
98
139
  return {
99
- id: actionId, input: 'punch', label: weapon ? `${displayName(weapon.traitGroupId)} attack` : 'Wing punch',
100
- ...(weapon ? { sourceTraitGroupId: weapon.traitGroupId } : {}), motionFamily: family, clip,
101
- effects: effectKind ? [effectFor(effectKind, 'contact', family === 'ranged' ? 'wing-front' : 'weapon', `${actionId}:${family}`)] : [],
140
+ id: actionId,
141
+ input: 'punch',
142
+ label: weapon ? `${displayName(weapon.traitGroupId)} attack` : 'Wing punch',
143
+ ...(weapon ? { sourceTraitGroupId: weapon.traitGroupId } : {}),
144
+ motionFamily: family,
145
+ clip,
146
+ effects: weapon ? punchEffects(weapon, targetOffset) : [],
147
+ targetOffset,
102
148
  };
103
149
  }
104
150
  if (actionId === 'kick') {
105
- const feet = selected.find(({ category }) => category.toLowerCase() === 'feet');
151
+ const feet = selected.find(({ kickPreset }) => kickPreset !== undefined);
106
152
  const combined = feet ? (definition.attachmentGroups?.[feet.traitGroupId]?.attachmentIds.length ?? 0) === 1 : false;
107
153
  const family = combined ? 'combined' : 'paired';
108
- return { id: actionId, input: 'kick', label: feet ? `${displayName(feet.traitGroupId)} spin kick` : 'Flying spin kick', ...(feet ? { sourceTraitGroupId: feet.traitGroupId } : {}), motionFamily: family, clip: tailorActionClip(definition, requiredClip(clips, `${definition.id}.action.kick.${family}`), selected, feet), effects: [effectFor('trail', 'contact', 'foot-front', `${actionId}:${family}`)] };
154
+ const clip = tailorActionClip(definition, requiredClip(clips, `${definition.id}.action.kick.${family}`), selected, feet);
155
+ return {
156
+ id: actionId,
157
+ input: 'kick',
158
+ label: feet ? `${displayName(feet.traitGroupId)} kick` : 'Flying spin kick',
159
+ ...(feet ? { sourceTraitGroupId: feet.traitGroupId } : {}),
160
+ motionFamily: family,
161
+ clip,
162
+ effects: [proceduralEffect('trail', 'contact', socketOrigin('foot-front'), `kick:${feet?.kickPreset ?? family}`, targetOffset, 'follow')],
163
+ targetOffset,
164
+ };
109
165
  }
110
166
  const specialChoice = listChiknSpecials(recipe, definition).find(({ id }) => id === actionId);
111
167
  if (!specialChoice?.sourceTraitGroupId)
112
168
  throw new Error(`Special is not available for this recipe: ${actionId}`);
113
169
  const profile = profiles.get(specialChoice.sourceTraitGroupId);
114
170
  const special = profile.special;
171
+ const clipId = special.preset === 'egg' ? `${definition.id}.action.special.egg` : `${definition.id}.action.special.${special.family}`;
172
+ const clip = tailorActionClip(definition, requiredClip(clips, clipId), selected, profile, special.preset === 'egg');
115
173
  return {
116
174
  ...specialChoice,
117
175
  motionFamily: special.family,
118
- clip: tailorActionClip(definition, requiredClip(clips, `${definition.id}.action.special.${special.family}`), selected, profile),
119
- effects: [effectFor(special.family === 'cast' ? 'burst' : special.family === 'tail' ? 'slash' : special.family, 'release', special.socketId, special.id)],
176
+ clip,
177
+ effects: specialEffects(profile, targetOffset),
178
+ targetOffset,
120
179
  };
121
180
  }
122
181
  /** Species-specific brawler and attack foundations generated from the rig setup pose. */
@@ -124,64 +183,103 @@ export function createChiknActionClips(definition) {
124
183
  const cached = clipCache.get(definition);
125
184
  if (cached)
126
185
  return cached;
127
- const species = definition.id;
128
- if (species !== 'chikn' && species !== 'roostr')
129
- throw new Error(`Unsupported Chikn rig species: ${species}`);
186
+ const species = speciesOf(definition);
130
187
  const make = (id, durationMs, tracks, cues = [], loop = false) => ({ schema: 'roost2d.animation/v1', id: `${species}.${id}`, durationMs, loop, ...(loop ? { loopMode: 'repeat' } : {}), defaultLayer: id.includes('action.') ? 'action' : 'base', cues, tracks });
131
- const track = (slot, frames) => {
132
- const neutral = neutralTransform(definition, slot);
133
- return { target: 'slot', targetId: slot, keyframes: frames.map(([timeMs, delta, durationMs]) => ({ timeMs, ...(durationMs === undefined ? {} : { durationMs }), x: neutral.x + (delta.x ?? 0), y: neutral.y + (delta.y ?? 0), rotation: neutral.rotation + (delta.rotation ?? 0), scaleX: neutral.scaleX * (delta.scaleX ?? 1), scaleY: neutral.scaleY * (delta.scaleY ?? 1), ease: 'power2.out' })) };
134
- };
135
- const phaseCues = (contact, duration, release = contact) => {
136
- const cues = [
137
- { id: 'anticipation', timeMs: 0, phase: 'anticipation' },
138
- { id: release === contact ? 'contact' : 'release', timeMs: release, phase: release === contact ? 'contact' : 'release' },
139
- ...(release === contact ? [] : [{ id: 'contact', timeMs: contact, phase: 'contact' }]),
140
- { id: 'recovery', timeMs: Math.min(duration - 1, Math.max(contact, release) + Math.round((duration - Math.max(contact, release)) * 0.35)), phase: 'recovery' },
141
- { id: 'complete', timeMs: duration, phase: 'complete' },
142
- ];
143
- return cues.sort((a, b) => a.timeMs - b.timeMs);
188
+ const track = (target, id, frames) => {
189
+ const neutral = target === 'bone' ? neutralBoneTransform(definition, id) : neutralTransform(definition, id);
190
+ return { target, targetId: id, keyframes: frames.map(([timeMs, delta, durationMs]) => ({ timeMs: durationMs === undefined ? timeMs : Math.max(0, timeMs - durationMs), ...(durationMs === undefined ? {} : { durationMs }), x: neutral.x + (delta.x ?? 0), y: neutral.y + (delta.y ?? 0), rotation: neutral.rotation + (delta.rotation ?? 0), scaleX: neutral.scaleX * (delta.scaleX ?? 1), scaleY: neutral.scaleY * (delta.scaleY ?? 1), ease: 'power2.out' })) };
144
191
  };
192
+ const slot = (id, frames) => track('slot', id, frames);
193
+ const cues = (contact, duration, release = contact) => [
194
+ { id: 'anticipation', timeMs: 0, phase: 'anticipation' },
195
+ ...(release === contact ? [{ id: 'contact', timeMs: contact, phase: 'contact' }] : [{ id: 'release', timeMs: release, phase: 'release' }, { id: 'contact', timeMs: contact, phase: 'contact' }]),
196
+ { id: 'recovery', timeMs: Math.min(duration - 1, Math.max(contact, release) + Math.round((duration - Math.max(contact, release)) * .35)), phase: 'recovery' },
197
+ { id: 'complete', timeMs: duration, phase: 'complete' },
198
+ ];
145
199
  const clips = [
146
- make('combat_idle', 800, [track('Torso', [[0, { y: 0 }], [400, { y: -1, rotation: -0.025 }, 400], [800, {}, 400]]), track('Wing A', [[0, { rotation: 0.18 }], [400, { rotation: 0.25 }, 400], [800, { rotation: 0.18 }, 400]])], [], true),
147
- make('dash', 260, [track('Torso', [[0, { x: -3, rotation: -.12 }], [90, { x: 12, rotation: -.18 }, 90], [260, {}, 170]]), track('Tail', [[0, { rotation: .25 }], [260, {}, 260]])], phaseCues(90, 260)),
148
- make('guard', 420, [track('Wing A', [[0, { x: 0, rotation: 0 }], [120, { x: 7, y: -4, rotation: -1.05 }, 120], [420, {}, 180]]), track('Head', [[0, {}], [120, { y: 3, rotation: .08 }, 120], [420, {}, 180]])], phaseCues(120, 420)),
200
+ make('combat_idle', 800, [slot('Torso', [[0, {}], [400, { y: -1, rotation: -.025 }, 400], [800, {}, 400]]), slot('Wing A', [[0, { rotation: .18 }], [400, { rotation: .25 }, 400], [800, { rotation: .18 }, 400]])], [], true),
201
+ make('dash', 260, [slot('Torso', [[0, { x: -3, rotation: -.12 }], [90, { x: 12, rotation: -.18 }, 90], [260, {}, 170]]), slot('Tail', [[0, { rotation: .25 }], [260, {}, 260]])], cues(90, 260)),
202
+ make('guard', 420, [slot('Wing A', [[0, {}], [120, { x: 7, y: -4, rotation: -1.05 }, 120], [420, {}, 180]]), slot('Head', [[0, {}], [120, { y: 3, rotation: .08 }, 120], [420, {}, 180]])], cues(120, 420)),
149
203
  ];
150
- const punch = (family, wingRotation, duration = 420) => make(`action.punch.${family}`, duration, [track('Torso', [[0, { rotation: -.08 }], [130, { x: -2, rotation: -.16 }, 130], [230, { x: 5, rotation: .12 }, 100], [duration, {}, duration - 230]]), track('Wing A', [[0, {}], [130, { rotation: -.55 }, 130], [230, { x: 10, rotation: wingRotation }, 100], [duration, {}, duration - 230]]), track('Head', [[0, {}], [230, { rotation: .08 }, 230], [duration, {}, duration - 230]])], phaseCues(230, duration));
151
- clips.push(punch('unarmed', -1.35), punch('blade', -2.15, 500), punch('blunt', -1.65, 540), punch('ranged', -1.05, 480), punch('casting', -1.25, 560));
152
- const kick = (family) => make(`action.kick.${family}`, 760, [track('Torso', [[0, { y: 3, rotation: -.12 }], [170, { y: -10, rotation: -.45 }, 170], [390, { y: -15, rotation: 2.7 }, 220], [560, { y: -7, rotation: 5.7 }, 170], [760, {}, 200]]), track('LegUpper A', [[0, { rotation: -.2 }], [170, { rotation: -.75 }, 170], [390, { rotation: family === 'paired' ? 1.45 : .85 }, 220], [560, { rotation: .3 }, 170], [760, {}, 200]]), track('LegUpper B', [[0, { rotation: .2 }], [170, { rotation: .65 }, 170], [390, { rotation: family === 'paired' ? -.85 : .35 }, 220], [760, {}, 370]]), track('Wing A', [[0, {}], [390, { rotation: -1.2 }, 390], [760, {}, 370]])], phaseCues(390, 760));
204
+ const punch = (family, rotation, duration) => make(`action.punch.${family}`, duration, [
205
+ slot('Torso', [[0, {}], [duration * .3, { x: -3, rotation: -.18 }, duration * .3], [duration * .52, { x: 5, rotation: .12 }, duration * .22], [duration, {}, duration * .48]]),
206
+ slot('Wing A', [[0, {}], [duration * .3, { rotation: -.55 }, duration * .3], [duration * .52, { x: 10, rotation }, duration * .22], [duration, {}, duration * .48]]),
207
+ slot('Head', [[0, {}], [duration * .52, { rotation: .08 }, duration * .52], [duration, {}, duration * .48]]),
208
+ ], cues(Math.round(duration * .52), duration));
209
+ clips.push(punch('unarmed', -1.35, 420), punch('blade', -2.15, 500), punch('blunt', -1.65, 560), punch('ranged', -1.05, 480), punch('casting', -1.25, 560));
210
+ const kick = (family) => make(`action.kick.${family}`, 760, [
211
+ slot('Torso', [[0, { y: 3, rotation: -.12 }], [170, { y: -10, rotation: -.45 }, 170], [390, { y: -15, rotation: 2.7 }, 220], [560, { y: -7, rotation: 5.7 }, 170], [760, {}, 200]]),
212
+ slot('LegUpper A', [[0, { rotation: -.2 }], [170, { rotation: -.75 }, 170], [390, { rotation: family === 'paired' ? 1.45 : .85 }, 220], [560, { rotation: .3 }, 170], [760, {}, 200]]),
213
+ slot('LegUpper B', [[0, { rotation: .2 }], [170, { rotation: .65 }, 170], [390, { rotation: family === 'paired' ? -.85 : .35 }, 220], [760, {}, 370]]),
214
+ slot('Wing A', [[0, {}], [390, { rotation: -1.2 }, 390], [760, {}, 370]]),
215
+ ], cues(390, 760));
153
216
  clips.push(kick('paired'), kick('combined'));
154
- const special = (family, release, duration) => make(`action.special.${family}`, duration, [track('Torso', [[0, {}], [release - 80, { x: -3, rotation: -.12 }, release - 80], [release, { x: 3, rotation: .08 }, 80], [duration, {}, duration - release]]), track(family === 'beam' ? 'Head' : family === 'projectile' || family === 'tail' ? 'Tail' : 'Wing A', [[0, {}], [release, { rotation: family === 'beam' ? -.16 : family === 'tail' ? 1.65 : -1.25, scaleX: 1.08, scaleY: 1.08 }, release], [duration, {}, duration - release]])], phaseCues(release + 20, duration, release));
155
- clips.push(special('beam', 260, 620), special('projectile', 300, 720), special('cast', 340, 760), special('burst', 280, 680), special('tail', 250, 610));
217
+ const special = (family, release, duration) => make(`action.special.${family}`, duration, [
218
+ slot('Torso', [[0, {}], [release - 80, { x: -3, rotation: -.12 }, release - 80], [release, { x: 3, rotation: .08 }, 80], [duration, {}, duration - release]]),
219
+ slot(family === 'beam' ? 'Head' : family === 'projectile' || family === 'tail' ? 'Tail' : 'Wing A', [[0, {}], [release, { rotation: family === 'beam' ? -.16 : family === 'tail' ? 1.65 : -1.25, scaleX: 1.08, scaleY: 1.08 }, release], [duration, {}, duration - release]]),
220
+ ], cues(release + 20, duration, release));
221
+ clips.push(special('beam', 280, 640), special('projectile', 320, 740), special('cast', 340, 760), special('burst', 300, 700), special('tail', 260, 640));
222
+ clips.push(make('action.special.egg', 1000, [
223
+ track('bone', 'pose', [[0, {}], [160, { scaleX: -1 }, 160], [800, { scaleX: -1 }, 640], [1000, {}, 200]]),
224
+ slot('Torso', [[0, {}], [160, { y: 1, rotation: .12 }, 160], [360, { y: 8, rotation: .34, scaleY: .9 }, 200], [800, { y: 5, rotation: .22, scaleY: .94 }, 440], [1000, {}, 200]]),
225
+ slot('Head', [[0, {}], [360, { x: -3, y: 7, rotation: .28 }, 360], [800, { x: -1, y: 3, rotation: .12 }, 440], [1000, {}, 200]]),
226
+ slot('Tail', [[0, {}], [360, { x: -3, y: -3, rotation: -.42 }, 360], [400, { x: -1, y: -5, rotation: -.6 }, 40], [800, { rotation: -.2 }, 400], [1000, {}, 200]]),
227
+ slot('LegUpper A', [[0, {}], [360, { rotation: -.38, scaleY: .92 }, 360], [800, { rotation: -.18 }, 440], [1000, {}, 200]]),
228
+ slot('LegUpper B', [[0, {}], [360, { rotation: .3, scaleY: .92 }, 360], [800, { rotation: .12 }, 440], [1000, {}, 200]]),
229
+ ], [
230
+ { id: 'anticipation', timeMs: 0, phase: 'anticipation' },
231
+ { id: 'turn-away', timeMs: 160, phase: 'anticipation' },
232
+ { id: 'release', timeMs: 400, phase: 'release' },
233
+ { id: 'contact', timeMs: 800, phase: 'contact' },
234
+ { id: 'recovery', timeMs: 800, phase: 'recovery' },
235
+ { id: 'turn-back', timeMs: 920, phase: 'recovery' },
236
+ { id: 'complete', timeMs: 1000, phase: 'complete' },
237
+ ]));
156
238
  const frozen = Object.freeze(clips.map(freezeClip));
157
239
  clipCache.set(definition, frozen);
158
240
  return frozen;
159
241
  }
160
- /** Adds deterministic per-trait recoil, sway and follow-through without changing gameplay movement. */
161
- function tailorActionClip(definition, base, profiles, driver) {
162
- const timeScale = 1 / (driver?.speed ?? 1);
163
- const durationMs = Math.max(1, Math.round(base.durationMs * timeScale));
242
+ function tailorActionClip(definition, base, profiles, driver, preserveTiming = false) {
243
+ const timeScale = preserveTiming ? 1 : 1 / (driver?.speed ?? 1);
244
+ const durationMs = preserveTiming ? base.durationMs : Math.max(1, Math.round(base.durationMs * timeScale));
164
245
  const scaleTime = (timeMs) => Math.min(durationMs, Math.round(timeMs * timeScale));
165
246
  const accentAt = base.cues?.find(({ phase }) => phase === 'contact' || phase === 'release')?.timeMs ?? base.durationMs * .5;
166
247
  const recoverAt = base.cues?.find(({ phase }) => phase === 'recovery')?.timeMs ?? base.durationMs * .72;
167
248
  const traitTracks = [];
168
249
  for (const profile of profiles) {
169
- const direction = stableVariation(profile.traitGroupId) >= .5 ? 1 : -1;
170
- const category = profile.category.toLowerCase();
171
- const rotation = profile.amplitude * direction * (category === 'tail' ? .16 : category === 'neck' || category === 'head' ? .1 : .065);
172
- const lift = profile.amplitude * (category === 'feet' ? -2.8 : -1.5);
173
- for (const targetId of profile.attachmentTargets) {
250
+ const isDriver = profile === driver;
251
+ for (const [index, targetId] of profile.attachmentTargets.entries()) {
174
252
  const neutral = neutralAttachmentTransform(definition, targetId);
175
- traitTracks.push({
176
- target: 'attachment',
177
- targetId,
178
- keyframes: [
179
- { timeMs: 0, durationMs: 0, ...neutral },
180
- { timeMs: scaleTime(accentAt), durationMs: Math.max(1, scaleTime(accentAt)), x: neutral.x, y: neutral.y + lift, rotation: neutral.rotation + rotation, scaleX: neutral.scaleX * (1 + .025 * profile.amplitude), scaleY: neutral.scaleY * (1 + .025 * profile.amplitude), ease: 'power2.out' },
181
- { timeMs: scaleTime(recoverAt), durationMs: Math.max(1, scaleTime(recoverAt - accentAt)), x: neutral.x, y: neutral.y + lift * .3, rotation: neutral.rotation - rotation * .25, scaleX: neutral.scaleX, scaleY: neutral.scaleY, ease: 'power2.out' },
182
- { timeMs: durationMs, durationMs: Math.max(1, durationMs - scaleTime(recoverAt)), ...neutral, ease: 'power2.out' },
183
- ],
184
- });
253
+ const motion = attachmentMotion(profile, isDriver, index);
254
+ let keyframes = [
255
+ { timeMs: 0, durationMs: 0, ...neutral },
256
+ { timeMs: 0, durationMs: Math.max(1, scaleTime(accentAt)), x: neutral.x + motion.x, y: neutral.y + motion.y, rotation: neutral.rotation + motion.rotation, scaleX: neutral.scaleX * motion.scaleX, scaleY: neutral.scaleY * motion.scaleY, ease: 'power2.out' },
257
+ { timeMs: scaleTime(accentAt), durationMs: Math.max(1, scaleTime(recoverAt - accentAt)), x: neutral.x, y: neutral.y + motion.y * .25, rotation: neutral.rotation - motion.rotation * .2, scaleX: neutral.scaleX, scaleY: neutral.scaleY, ease: 'power2.out' },
258
+ { timeMs: scaleTime(recoverAt), durationMs: Math.max(1, durationMs - scaleTime(recoverAt)), ...neutral, ease: 'power2.out' },
259
+ ];
260
+ if (isDriver && profile.special?.preset === 'egg') {
261
+ keyframes = [
262
+ { timeMs: 0, durationMs: 0, ...neutral, visible: true },
263
+ { timeMs: 0, durationMs: 360, ...neutral, y: neutral.y + 2, rotation: neutral.rotation - .18, visible: true, ease: 'power2.out' },
264
+ { timeMs: 360, durationMs: 40, ...neutral, y: neutral.y - 2, rotation: neutral.rotation - .28, scaleX: neutral.scaleX * .82, scaleY: neutral.scaleY * .82, visible: true, ease: 'power2.out' },
265
+ { timeMs: 400, durationMs: 0, ...neutral, y: neutral.y - 2, rotation: neutral.rotation - .28, scaleX: neutral.scaleX * .82, scaleY: neutral.scaleY * .82, visible: false, ease: 'none' },
266
+ { timeMs: 800, durationMs: 0, ...neutral, scaleX: neutral.scaleX * .2, scaleY: neutral.scaleY * .2, visible: true, ease: 'none' },
267
+ { timeMs: 800, durationMs: 80, ...neutral, scaleX: neutral.scaleX * 1.14, scaleY: neutral.scaleY * 1.14, visible: true, ease: 'back.out(2)' },
268
+ { timeMs: 880, durationMs: 120, ...neutral, visible: true, ease: 'power2.out' },
269
+ ];
270
+ }
271
+ else if (isDriver && (profile.special?.preset === 'disk' || profile.special?.preset === 'pan')) {
272
+ const release = scaleTime(accentAt);
273
+ const recovery = scaleTime(recoverAt);
274
+ keyframes = [
275
+ { timeMs: 0, durationMs: 0, ...neutral, visible: true },
276
+ { timeMs: 0, durationMs: Math.max(1, release), x: neutral.x + motion.x, y: neutral.y + motion.y, rotation: neutral.rotation + motion.rotation, scaleX: neutral.scaleX * motion.scaleX, scaleY: neutral.scaleY * motion.scaleY, visible: true, ease: 'power2.out' },
277
+ { timeMs: release, durationMs: 0, x: neutral.x + motion.x, y: neutral.y + motion.y, rotation: neutral.rotation + motion.rotation, scaleX: neutral.scaleX * motion.scaleX, scaleY: neutral.scaleY * motion.scaleY, visible: false, ease: 'none' },
278
+ { timeMs: recovery, durationMs: 0, ...neutral, scaleX: neutral.scaleX * .2, scaleY: neutral.scaleY * .2, visible: true, ease: 'none' },
279
+ { timeMs: recovery, durationMs: Math.max(1, durationMs - recovery), ...neutral, visible: true, ease: 'back.out(2)' },
280
+ ];
281
+ }
282
+ traitTracks.push({ target: 'attachment', targetId, keyframes });
185
283
  }
186
284
  }
187
285
  return {
@@ -189,11 +287,134 @@ function tailorActionClip(definition, base, profiles, driver) {
189
287
  durationMs,
190
288
  cues: base.cues?.map((cue) => ({ ...cue, timeMs: scaleTime(cue.timeMs) })),
191
289
  tracks: [
192
- ...base.tracks.map((track) => ({ ...track, keyframes: track.keyframes.map((keyframe) => ({ ...keyframe, timeMs: scaleTime(keyframe.timeMs), ...(keyframe.durationMs === undefined ? {} : { durationMs: Math.max(0, scaleTime(keyframe.durationMs)) }) })) })),
290
+ ...base.tracks.map((entry) => ({ ...entry, keyframes: entry.keyframes.map((keyframe) => ({ ...keyframe, timeMs: scaleTime(keyframe.timeMs), ...(keyframe.durationMs === undefined ? {} : { durationMs: Math.max(0, scaleTime(keyframe.durationMs)) }) })) })),
193
291
  ...traitTracks,
194
292
  ],
195
293
  };
196
294
  }
295
+ function attachmentMotion(profile, driver, index) {
296
+ const paired = index % 2 === 0 ? 1 : -1;
297
+ if (driver && profile.punchPreset) {
298
+ const values = {
299
+ katana: [14, -5, -2.65, 1.04, .98], sword: [12, -7, -2.2, 1.04, .98], snips: [11, paired * 3, paired * .82, 1.08, 1.02], thrust: [20, -2, -.25, 1.02, 1], golf: [8, -9, -2.8, 1.04, .98], pickaxe: [7, -11, -2.45, 1.04, .98], 'heavy-swing': [8, -7, -1.9, 1.04, 1.02], 'light-swing': [12, -5, -2.25, 1.05, .98], gun: [5, 1, -.18, 1.02, 1], wand: [9, -6, -1.35, 1.05, 1],
300
+ };
301
+ const [x, y, rotation, scaleX, scaleY] = values[profile.punchPreset];
302
+ return { x, y, rotation, scaleX, scaleY };
303
+ }
304
+ if (driver && profile.kickPreset) {
305
+ const values = {
306
+ natural: [12 * paired, -5, 1.15 * paired, 1.08, 1.04], fast: [18 * paired, -8, 1.7 * paired, 1.08, 1.02], heavy: [15 * paired, -3, 1.15 * paired, 1.12, 1.08], roller: [17 * paired, -7, 2.5 * paired, 1.08, 1.04], stiletto: [21 * paired, -2, .85 * paired, 1.1, .98], spur: [20 * paired, -5, 1.35 * paired, 1.1, 1], pendulum: [13 * paired, 5, 2.2 * paired, 1.12, 1.08], ironclaw: [19 * paired, -4, 1.65 * paired, 1.1, 1],
307
+ };
308
+ const [x, y, rotation, scaleX, scaleY] = values[profile.kickPreset];
309
+ return { x, y, rotation, scaleX, scaleY };
310
+ }
311
+ if (driver && profile.special?.preset?.startsWith('tail-'))
312
+ return { x: -11, y: -4, rotation: 1.65, scaleX: 1.1, scaleY: 1.04 };
313
+ const categoryDirection = profile.category === 'Tail' ? -1 : profile.category === 'Feet' ? paired : 1;
314
+ const rotations = { rigid: .04, soft: .1, dangling: .16, cloth: .2, feather: .14, bulky: .06, elastic: .22 };
315
+ return { x: 0, y: -1.5 * profile.amplitude, rotation: rotations[profile.secondaryMotion] * categoryDirection, scaleX: 1 + .025 * profile.amplitude, scaleY: 1 + .025 * profile.amplitude };
316
+ }
317
+ function punchEffects(profile, target) {
318
+ const origin = attachmentOrigin(profile);
319
+ if (profile.punchPreset === 'gun')
320
+ return [
321
+ proceduralEffect('projectile', 'contact', origin, `${profile.traitGroupId}:round`, target, 'detached', { kind: 'linear', targetOffset: target, rotationTurns: 0 }, { color: 0xffd36b, radius: 4, durationMs: 360 }),
322
+ proceduralEffect('burst', 'contact', origin, `${profile.traitGroupId}:muzzle`, target, 'follow', undefined, { color: 0xffa726, secondaryColor: 0xffffdc, radius: 12, durationMs: 150 }),
323
+ ];
324
+ if (profile.motionFamily === 'blade')
325
+ return [proceduralEffect('slash', 'contact', origin, `${profile.traitGroupId}:slash`, target, 'follow', undefined, { color: 0xffe79a, length: 100, width: profile.punchPreset === 'katana' ? 5 : 8 })];
326
+ if (profile.motionFamily === 'casting')
327
+ return [proceduralEffect('burst', 'contact', origin, `${profile.traitGroupId}:cast`, target, 'follow')];
328
+ if (profile.motionFamily === 'blunt')
329
+ return [proceduralEffect('trail', 'contact', origin, `${profile.traitGroupId}:swing`, target, 'follow', undefined, { width: profile.punchPreset === 'heavy-swing' ? 11 : 7 })];
330
+ return [];
331
+ }
332
+ function specialEffects(profile, target) {
333
+ const special = profile.special;
334
+ const origin = special.origin;
335
+ const preset = special.preset;
336
+ if (preset === 'egg')
337
+ return [cloneEffect(profile.attachmentTargets[0], 'release', origin, special.id, target, 400, 28, 1)];
338
+ if (preset === 'disk')
339
+ return [cloneEffect(profile.attachmentTargets[0], 'release', origin, special.id, target, 470, 36, 2)];
340
+ if (preset === 'pan')
341
+ return [cloneEffect(profile.attachmentTargets[0], 'release', origin, special.id, target, 500, 42, 1)];
342
+ if (preset === 'laser')
343
+ return [{ ...proceduralEffect('beam', 'release', origin, special.id, target, 'detached', undefined, { color: 0xff335a, secondaryColor: 0xffffff, length: Math.hypot(target.x, target.y), width: 9, durationMs: 260 }), socketId: 'eyes' }];
344
+ if (preset === 'sonic')
345
+ return [proceduralEffect('burst', 'release', origin, special.id, target, 'follow', undefined, { color: 0x73dcff, secondaryColor: 0xffffff, radius: 24, durationMs: 360 })];
346
+ if (preset === 'flame')
347
+ return [proceduralEffect('trail', 'release', origin, special.id, target, 'follow', { kind: 'linear', targetOffset: { x: target.x * .55, y: target.y * .55 } }, { color: 0xff7a24, secondaryColor: 0xffed6a, width: 18, durationMs: 390 })];
348
+ if (preset === 'exhaust')
349
+ return [proceduralEffect('trail', 'release', origin, special.id, { x: -Math.max(90, Math.abs(target.x) * .55), y: -target.y * .2 }, 'follow', undefined, { color: 0x7b8194, secondaryColor: 0xd9deeb, width: 18, durationMs: 360 })];
350
+ if (preset === 'tail-stab' || preset === 'tail-sweep' || preset === 'tail-snap')
351
+ return [proceduralEffect('slash', 'release', origin, special.id, target, 'follow', undefined, { color: 0xb8f26a, width: preset === 'tail-stab' ? 5 : 10, radius: 30, durationMs: 300 })];
352
+ if (preset === 'tail-slam')
353
+ return [proceduralEffect('burst', 'contact', origin, special.id, target, 'follow', undefined, { color: 0x67c9ff, radius: 34, width: 12, durationMs: 360 })];
354
+ const colors = { wand: 0x9d71ff, energy: 0xffd84f, liquid: 0x5ddcff };
355
+ const kind = special.family === 'cast' ? 'burst' : special.family === 'tail' ? 'slash' : special.family;
356
+ return [proceduralEffect(kind, 'release', origin, special.id, target, 'follow', undefined, { color: colors[preset] ?? 0x9d71ff })];
357
+ }
358
+ function cloneEffect(attachmentId, cueId, origin, id, target, durationMs, arcHeight, rotationTurns) {
359
+ return proceduralEffect('projectile', cueId, origin, id, target, 'detached', { kind: 'arc', targetOffset: target, arcHeight, rotationTurns }, { durationMs, radius: 10, color: 0xffffff, visual: { kind: 'attachment-clone', attachmentId } });
360
+ }
361
+ function proceduralEffect(kind, cueId, origin, id, target, space, trajectory, values = {}) {
362
+ const colors = { beam: 0xff355e, slash: 0xffdf70, projectile: 0xfff2b2, burst: 0x9d71ff, trail: 0x74d7ff };
363
+ const actualTrajectory = trajectory ?? ((kind === 'projectile' || kind === 'trail' || kind === 'beam') ? { kind: 'linear', targetOffset: target } : undefined);
364
+ return {
365
+ id,
366
+ kind,
367
+ cueId,
368
+ socketId: origin.target === 'socket' ? origin.targetId : 'weapon',
369
+ origin,
370
+ space,
371
+ ...(actualTrajectory ? { trajectory: actualTrajectory } : {}),
372
+ visual: values.visual ?? { kind: 'procedural' },
373
+ durationMs: values.durationMs ?? (kind === 'beam' ? 260 : kind === 'projectile' ? 480 : 300),
374
+ color: values.color ?? colors[kind],
375
+ secondaryColor: values.secondaryColor ?? 0xffffff,
376
+ length: values.length ?? (kind === 'beam' ? Math.hypot(target.x, target.y) : 90),
377
+ width: values.width ?? (kind === 'beam' ? 9 : 7),
378
+ radius: values.radius ?? (kind === 'projectile' ? 10 : 18),
379
+ distance: values.distance ?? Math.hypot(target.x, target.y),
380
+ };
381
+ }
382
+ function specialFor(preset, groupId, attachmentId, category, offset) {
383
+ const names = {
384
+ egg: { suffix: 'egg', label: 'lay and launch', family: 'projectile' },
385
+ laser: { suffix: 'laser', label: 'beam', family: 'beam' },
386
+ flame: { suffix: 'flame', label: 'flame', family: 'burst' },
387
+ sonic: { suffix: 'sonic', label: 'sonic pulse', family: 'burst' },
388
+ disk: { suffix: 'disk', label: 'disk throw', family: 'projectile' },
389
+ pan: { suffix: 'pan', label: 'pan throw', family: 'projectile' },
390
+ wand: { suffix: 'cast', label: 'wand cast', family: 'cast' },
391
+ energy: { suffix: 'energy', label: 'energy burst', family: 'cast' },
392
+ liquid: { suffix: 'liquid', label: 'beaker splash', family: 'projectile' },
393
+ exhaust: { suffix: 'exhaust', label: 'exhaust burst', family: 'burst' },
394
+ 'tail-stab': { suffix: 'tail', label: 'tail stab', family: 'tail' },
395
+ 'tail-sweep': { suffix: 'tail', label: 'tail sweep', family: 'tail' },
396
+ 'tail-snap': { suffix: 'tail', label: 'hydra snap', family: 'tail' },
397
+ 'tail-slam': { suffix: 'tail', label: 'tail slam', family: 'tail' },
398
+ };
399
+ const named = names[preset];
400
+ const useAttachment = preset === 'egg' || preset === 'laser' || preset === 'disk' || preset === 'pan' || preset === 'sonic' || preset === 'flame' || preset === 'wand' || preset === 'tail-stab' || preset === 'tail-sweep' || preset === 'tail-snap' || preset === 'tail-slam';
401
+ const socketId = preset === 'laser' || (preset === 'energy' && category === 'Head') ? 'eyes' : preset === 'exhaust' ? 'tail' : category === 'Tail' ? 'tail' : 'weapon';
402
+ return {
403
+ id: `${groupId}:${named.suffix}`,
404
+ label: `${displayName(groupId)} ${named.label}`,
405
+ family: named.family,
406
+ preset,
407
+ origin: useAttachment ? { target: 'attachment', targetId: attachmentId, x: offset.x, y: offset.y } : socketOrigin(socketId, offset),
408
+ };
409
+ }
410
+ function socketOrigin(targetId, offset = { x: 0, y: 0 }) { return { target: 'socket', targetId, x: offset.x, y: offset.y }; }
411
+ function attachmentOrigin(profile) { return { target: 'attachment', targetId: profile.attachmentTargets[0], x: profile.originOffset.x, y: profile.originOffset.y }; }
412
+ function normaliseTarget(value) {
413
+ const target = value ?? DEFAULT_TARGET;
414
+ if (!Number.isFinite(target.x) || !Number.isFinite(target.y))
415
+ throw new Error('targetOffset must contain finite x and y values');
416
+ return Object.freeze({ x: target.x, y: target.y });
417
+ }
197
418
  function freezeClip(clip) {
198
419
  for (const track of clip.tracks) {
199
420
  for (const keyframe of track.keyframes)
@@ -222,32 +443,18 @@ function assertRecipe(recipe, definition) {
222
443
  if (!definition.attachmentGroups?.[id])
223
444
  throw new Error(`Unknown trait group ${id}`);
224
445
  }
225
- function specialFor(groupId) {
226
- const id = groupId.toLowerCase();
227
- if (id.includes('laser-eye'))
228
- return { id: `${groupId}:laser`, label: `${displayName(groupId)} beam`, family: 'beam', socketId: 'eyes' };
229
- if (TAIL_ATTACK.test(id))
230
- return { id: `${groupId}:tail`, label: `${displayName(groupId)} strike`, family: 'tail', socketId: 'tail' };
231
- if (/(?:golden-egg|very-fresh-egg|omelette)/.test(id))
232
- return { id: `${groupId}:egg`, label: `${displayName(groupId)} throw`, family: 'projectile', socketId: id.startsWith('tail/') ? 'tail' : 'wing-front' };
233
- if (CASTING.test(id))
234
- return { id: `${groupId}:cast`, label: `${displayName(groupId)} special`, family: 'cast', socketId: id.startsWith('head/') ? 'eyes' : 'weapon' };
235
- if (RANGED.test(id))
236
- return { id: `${groupId}:shot`, label: `${displayName(groupId)} shot`, family: 'projectile', socketId: 'weapon' };
237
- if (/(?:gas-guzzler|fat-pipes|smokestack|hothead|toot-toot)/.test(id))
238
- return { id: `${groupId}:burst`, label: `${displayName(groupId)} burst`, family: 'burst', socketId: id.startsWith('tail/') ? 'tail' : 'weapon' };
239
- return undefined;
240
- }
241
- function effectFor(kind, cueId, socketId, id) {
242
- const colors = { beam: 0xff355e, slash: 0xffdf70, projectile: 0xfff2b2, burst: 0x9d71ff, trail: 0x74d7ff };
243
- return { id, kind, cueId, socketId, durationMs: kind === 'beam' ? 260 : kind === 'projectile' ? 480 : 300, color: colors[kind], secondaryColor: 0xffffff, length: kind === 'beam' ? 180 : 90, width: kind === 'beam' ? 9 : 7, radius: kind === 'projectile' ? 10 : 18, distance: kind === 'projectile' ? 170 : 80 };
244
- }
245
446
  function neutralTransform(definition, slotId) {
246
447
  const attachmentId = definition.defaultSkinId ? definition.skins?.[definition.defaultSkinId]?.[slotId] : undefined;
247
448
  const attachment = definition.attachments.find(({ id }) => id === attachmentId) ?? definition.attachments.find(({ slotId: candidate }) => candidate === slotId);
248
449
  const bone = definition.bones.find(({ id }) => id === attachment?.boneId);
249
450
  return { x: bone?.x ?? attachment?.x ?? 0, y: bone?.y ?? attachment?.y ?? 0, rotation: bone?.rotation ?? attachment?.rotation ?? 0, scaleX: bone?.scaleX ?? attachment?.scaleX ?? 1, scaleY: bone?.scaleY ?? attachment?.scaleY ?? 1 };
250
451
  }
452
+ function neutralBoneTransform(definition, boneId) {
453
+ const bone = definition.bones.find(({ id }) => id === boneId);
454
+ if (!bone)
455
+ throw new Error(`Unknown action bone: ${boneId}`);
456
+ return { x: bone.x, y: bone.y, rotation: bone.rotation, scaleX: bone.scaleX, scaleY: bone.scaleY };
457
+ }
251
458
  function neutralAttachmentTransform(definition, attachmentId) {
252
459
  const attachment = definition.attachments.find(({ id }) => id === attachmentId);
253
460
  if (!attachment)
@@ -260,7 +467,10 @@ function requiredClip(clips, id) {
260
467
  throw new Error(`Unknown Chikn action clip: ${id}`);
261
468
  return clip;
262
469
  }
470
+ function speciesOf(definition) {
471
+ if (definition.id !== 'chikn' && definition.id !== 'roostr')
472
+ throw new Error(`Unsupported Chikn rig species: ${definition.id}`);
473
+ return definition.id;
474
+ }
263
475
  function displayName(groupId) { return groupId.split('/').at(-1).split('-').map((word) => word ? word[0].toUpperCase() + word.slice(1) : '').join(' '); }
264
- function stableVariation(value) { let hash = 2166136261; for (const char of value)
265
- hash = Math.imul(hash ^ char.charCodeAt(0), 16777619); return (hash >>> 0) / 0xffffffff; }
266
476
  //# sourceMappingURL=actions.js.map