Skip to content

Commit 279dbe5

Browse files
committed
✨ Add Texture Slot keyframes and Variant stacks
- Variant keyframes apply several Variants in order; old keyframes migrate in 1.11.0 - Texture Slot keyframes switch slots during an animation, on 1.21.4+ only - Animate mode previews each bone from the animation's start state, leaving Edit mode alone - Each frame's effects export to one function, shared by baked, storage, and loop paths - Plugin JSON exports Variants and `variant` keyframes instead of flattening them - Keyframe panel edits are undoable and mark the project unsaved - Fix Variant keyframes never applying with storage animations - Fix last-frame Variant and function keyframes skipped in loops without a moving Locator
1 parent 119705c commit 279dbe5

40 files changed

Lines changed: 2379 additions & 475 deletions

‎schemas/plugin-blueprint.schema.json‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@
3636
}
3737
}
3838
},
39+
"variants": {
40+
"type": "object",
41+
"default": {},
42+
"patternProperties": {
43+
"^[a-z0-9_]+$": {
44+
"$ref": "#/definitions/variant"
45+
}
46+
}
47+
},
3948
"nodes": {
4049
"type": "object",
4150
"default": {},
@@ -149,6 +158,41 @@
149158
}
150159
}
151160
},
161+
"variant": {
162+
"type": "object",
163+
"description": "A named set of changes to the rig. Applying a Variant changes every node it doesn't exclude.",
164+
"required": ["texture_slots", "excluded_nodes", "entity_properties"],
165+
"properties": {
166+
"is_default": {
167+
"type": "boolean",
168+
"default": false,
169+
"description": "Whether this is the default Variant. Exactly one Variant is the default."
170+
},
171+
"texture_slots": {
172+
"$ref": "#/definitions/texture_slot_changes",
173+
"description": "The texture each slot switches to, on nodes this Variant doesn't exclude. Slots not listed are unchanged. The default Variant lists every slot with its default texture."
174+
},
175+
"excluded_nodes": {
176+
"type": "array",
177+
"default": [],
178+
"description": "Keys from the 'nodes' section this Variant leaves unchanged, including their texture slots.",
179+
"items": {
180+
"type": "string",
181+
"pattern": "^[a-z0-9_]+$"
182+
}
183+
},
184+
"entity_properties": {
185+
"type": "object",
186+
"default": {},
187+
"description": "Entity properties this Variant merges onto nodes, by key from the 'nodes' section. Each value has the same shape as that node's 'entity_properties'. For the default Variant, these are the nodes' default properties.",
188+
"patternProperties": {
189+
"^[a-z0-9_]+$": {
190+
"type": "object"
191+
}
192+
}
193+
}
194+
}
195+
},
152196
"node": {
153197
"type": "object",
154198
"required": ["type"],
@@ -674,14 +718,25 @@
674718
"global_keyframes": {
675719
"type": "object",
676720
"default": {},
721+
"description": "Keyframes that aren't tied to a node. When a 'variant' and a 'texture_slot' keyframe share a timestamp, the Variants apply first.",
677722
"properties": {
723+
"variant": {
724+
"type": "object",
725+
"default": {},
726+
"description": "A collection of variant keyframes, identified by a floating-point timestamp.",
727+
"patternProperties": {
728+
"^\\d+\\.\\d+$": {
729+
"$ref": "#/definitions/variant_keyframe"
730+
}
731+
}
732+
},
678733
"texture_slot": {
679734
"type": "object",
680735
"default": {},
681-
"description": "A collection of texture slot keyframes, identified by a floating-point timestamp.",
736+
"description": "A collection of texture slot keyframes, identified by a floating-point timestamp. Each one changes every node with faces in the listed slots, regardless of Variant exclusions.",
682737
"patternProperties": {
683738
"^\\d+\\.\\d+$": {
684-
"$ref": "#/definitions/texture_slot_keyframe"
739+
"$ref": "#/definitions/texture_slot_changes"
685740
}
686741
}
687742
},
@@ -751,10 +806,19 @@
751806
"minItems": 3,
752807
"maxItems": 3
753808
},
754-
"texture_slot_keyframe": {
809+
"variant_keyframe": {
810+
"type": "array",
811+
"description": "Keys from the 'variants' section, applied in order. Later Variants override earlier ones on the nodes they change.",
812+
"minItems": 1,
813+
"items": {
814+
"type": "string",
815+
"pattern": "^[a-z0-9_]+$"
816+
}
817+
},
818+
"texture_slot_changes": {
755819
"type": "object",
756820
"default": {},
757-
"description": "Maps keys from the 'texture_slots' section to the texture each slot switches to. Slots not listed are unchanged.",
821+
"description": "Maps keys from the 'texture_slots' section to the key of the texture each slot switches to. Slots not listed are unchanged.",
758822
"patternProperties": {
759823
"^[a-z0-9_]+$": {
760824
"type": "string",

‎src/animationPreview.ts‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { EFFECT_ANIMATOR_CHANNELS } from './mods/customKeyframes'
2+
import { VanillaBlockDisplay } from './outliner/vanillaBlockDisplay'
3+
import { VanillaItemDisplay } from './outliner/vanillaItemDisplay'
4+
import { getSlotDefaultTexture, getSlotPreviewTexture, getSlotTextures } from './textureSlots'
5+
import { Variant } from './variants'
6+
7+
/**
8+
* What an animation has applied to the rig at the previewed time. Only exists in Animate mode with
9+
* an animation selected. Otherwise the editor shows the Variant selected in the Variants panel.
10+
*/
11+
interface AnimationPreviewState {
12+
/** Every Variant applied so far, in order. */
13+
variants: Variant[]
14+
/** Bone UUID -> slot UUID -> texture UUID. Slots left out show their default texture. */
15+
boneSlots: Map<string, Map<string, string>>
16+
}
17+
18+
type PreviewEvent =
19+
| { time: number; variants: Variant[] }
20+
| { time: number; textureSlots: Record<string, string> }
21+
22+
let state: AnimationPreviewState | undefined
23+
let stateKey = ''
24+
25+
/**
26+
* The Variant whose display entity config `node` shows: the last applied Variant that changes it.
27+
*/
28+
export function getPreviewVariant(node: OutlinerNode): Variant | undefined {
29+
if (!state) return Variant.selected
30+
for (let i = state.variants.length - 1; i >= 0; i--) {
31+
const variant = state.variants[i]
32+
if (variant.excludedNodes.has(node.uuid)) continue
33+
const configs = (node as { configs?: { variants?: Record<string, unknown> } }).configs
34+
if (variant.isDefault || configs?.variants?.[variant.uuid] !== undefined) return variant
35+
}
36+
return Variant.getDefault()
37+
}
38+
39+
/**
40+
* The texture a slot shows on faces of `bone`.
41+
*/
42+
export function getPreviewSlotTexture(slot: Texture, bone: OutlinerNode | undefined) {
43+
if (!state) {
44+
// The selected Variant leaves the slots it sets on excluded bones at their default.
45+
const variant = Variant.selected
46+
const excluded =
47+
bone && variant?.excludedNodes.has(bone.uuid) && variant.slotTextures.has(slot.uuid)
48+
return excluded ? getSlotDefaultTexture(slot) : getSlotPreviewTexture(slot)
49+
}
50+
const uuid = bone && state.boneSlots.get(bone.uuid)?.get(slot.uuid)
51+
const textures = getSlotTextures(slot)
52+
return textures.find(t => t.uuid === uuid) ?? textures[0]
53+
}
54+
55+
function getAnimationStartVariants(animation: _Animation): Variant[] {
56+
const variants = (animation.preview_variants ?? [])
57+
.map(uuid => Variant.getByUUID(uuid))
58+
.filter((v): v is Variant => !!v)
59+
return variants.length ? variants : [Variant.getDefault()]
60+
}
61+
62+
function getPreviewEvents(animation: _Animation, time: number): PreviewEvent[] {
63+
const effects = animation.animators.effects as unknown as EffectAnimator | undefined
64+
const events: PreviewEvent[] = []
65+
if (effects && !effects.muted[EFFECT_ANIMATOR_CHANNELS.VARIANT]) {
66+
for (const kf of effects[EFFECT_ANIMATOR_CHANNELS.VARIANT] as _Keyframe[]) {
67+
if (kf.time <= time) events.push({ time: kf.time, variants: kf.variants ?? [] })
68+
}
69+
}
70+
if (effects && !effects.muted[EFFECT_ANIMATOR_CHANNELS.TEXTURE_SLOT]) {
71+
for (const kf of effects[EFFECT_ANIMATOR_CHANNELS.TEXTURE_SLOT] as _Keyframe[]) {
72+
if (kf.time <= time)
73+
events.push({ time: kf.time, textureSlots: kf.texture_slots ?? {} })
74+
}
75+
}
76+
// Variants apply before texture slots on the same frame, like in-game.
77+
return events.sort(
78+
(a, b) => a.time - b.time || Number('textureSlots' in a) - Number('textureSlots' in b)
79+
)
80+
}
81+
82+
/**
83+
* Replays what `animation` applies to the rig, from its start state up to `time`.
84+
*/
85+
export function computeAnimationPreviewState(
86+
animation: _Animation,
87+
time: number
88+
): AnimationPreviewState {
89+
const result: AnimationPreviewState = { variants: [], boneSlots: new Map() }
90+
const bones = Group.all
91+
92+
const applyVariant = (variant: Variant) => {
93+
result.variants.push(variant)
94+
for (const bone of bones) {
95+
if (variant.excludedNodes.has(bone.uuid)) continue
96+
if (variant.isDefault) {
97+
result.boneSlots.delete(bone.uuid)
98+
continue
99+
}
100+
for (const [slotUuid, textureUuid] of variant.slotTextures) {
101+
setBoneSlot(bone.uuid, slotUuid, textureUuid)
102+
}
103+
}
104+
}
105+
const applyTextureSlots = (textureSlots: Record<string, string>) => {
106+
for (const [slotUuid, textureUuid] of Object.entries(textureSlots)) {
107+
for (const bone of bones) setBoneSlot(bone.uuid, slotUuid, textureUuid)
108+
}
109+
}
110+
const setBoneSlot = (boneUuid: string, slotUuid: string, textureUuid: string) => {
111+
let slots = result.boneSlots.get(boneUuid)
112+
if (!slots) result.boneSlots.set(boneUuid, (slots = new Map()))
113+
slots.set(slotUuid, textureUuid)
114+
}
115+
116+
for (const variant of getAnimationStartVariants(animation)) applyVariant(variant)
117+
applyTextureSlots(animation.preview_texture_slots ?? {})
118+
for (const event of getPreviewEvents(animation, time)) {
119+
if ('variants' in event) event.variants.forEach(applyVariant)
120+
else applyTextureSlots(event.textureSlots)
121+
}
122+
return result
123+
}
124+
125+
function getStateKey(previewState: AnimationPreviewState | undefined) {
126+
if (!previewState) return ''
127+
const slots = [...previewState.boneSlots].map(([bone, map]) => [bone, [...map]])
128+
return JSON.stringify([previewState.variants.map(v => v.uuid), slots])
129+
}
130+
131+
/**
132+
* Forgets the previewed animation, so the next update starts fresh. Used when switching projects.
133+
*/
134+
export function resetAnimationPreview() {
135+
state = undefined
136+
stateKey = ''
137+
}
138+
139+
/**
140+
* Re-renders everything that depends on which Variant or slot texture is shown.
141+
*/
142+
export function refreshVariantPreview() {
143+
Canvas.updateAllFaces()
144+
VanillaBlockDisplay.forceUpdateAll()
145+
VanillaItemDisplay.forceUpdateAll()
146+
}
147+
148+
/**
149+
* Previews the selected animation's Variants and texture slots in Animate mode, or the Variants
150+
* panel's selection everywhere else. Only re-renders when what's shown changes.
151+
*/
152+
export function updateAnimationPreview() {
153+
const animation = Modes.animate ? Blockbench.Animation.selected : undefined
154+
const next = animation ? computeAnimationPreviewState(animation, animation.time) : undefined
155+
const nextKey = getStateKey(next)
156+
const wasActive = !!state
157+
state = next
158+
if (nextKey === stateKey && wasActive === !!next) return
159+
stateKey = nextKey
160+
refreshVariantPreview()
161+
}

‎src/dialogs/animationProperties/animationProperties.svelte‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,26 @@
99
<script lang="ts">
1010
import Collection from '../../svelteComponents/dialogItems/collection.svelte'
1111
import Select from '../../svelteComponents/dialogItems/select.svelte'
12+
import SlotTexturesEditor from '../../svelteComponents/slotTexturesEditor.svelte'
13+
import VariantStackEditor from '../../svelteComponents/variantStackEditor.svelte'
1214
import { getAvailableNodes } from '../../util/excludedNodes'
1315
1416
interface Props {
1517
animationName: Observable<string>
1618
loopMode: Observable<string>
1719
loopDelay: Observable<number>
1820
excludedNodes: Observable<Array<{ name: string; value: string }>>
21+
previewVariants: Observable<string[]>
22+
previewTextureSlots: Observable<Record<string, string>>
1923
}
2024
2125
let {
2226
animationName = $bindable(),
2327
loopMode = $bindable(),
2428
loopDelay = $bindable(),
2529
excludedNodes = $bindable(),
30+
previewVariants,
31+
previewTextureSlots,
2632
}: Props = $props()
2733
2834
const AVAILABLE_BONES = getAvailableNodes(excludedNodes.get())
@@ -92,7 +98,27 @@
9298
availableItems={AVAILABLE_BONES}
9399
bind:includedItems={excludedNodes}
94100
/>
101+
102+
<VariantStackEditor
103+
title={translate('dialog.animation_properties.preview_variants.title')}
104+
description={translate('dialog.animation_properties.preview_variants.description')}
105+
emptyText={translate('dialog.animation_properties.preview_variants.empty')}
106+
value={$previewVariants}
107+
onchange={value => previewVariants.set(value)}
108+
/>
109+
110+
<SlotTexturesEditor
111+
title={translate('dialog.animation_properties.preview_texture_slots.title')}
112+
description={translate('dialog.animation_properties.preview_texture_slots.description')}
113+
emptyText={translate('dialog.animation_properties.preview_texture_slots.empty')}
114+
value={$previewTextureSlots}
115+
onchange={value => previewTextureSlots.set(value)}
116+
/>
95117
</div>
96118

97119
<style>
120+
div {
121+
overflow-y: auto;
122+
max-height: 75vh;
123+
}
98124
</style>

‎src/dialogs/animationProperties/animationProperties.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export function openAnimationPropertiesDialog(animation: _Animation) {
1111
const loopMode = observable(animation.loop as string)
1212
const loopDelay = observable(Number(animation.loop_delay) || 0)
1313
const excludedNodes = observable(animation.excluded_nodes)
14+
const previewVariants = observable([...animation.preview_variants])
15+
const previewTextureSlots = observable({ ...animation.preview_texture_slots })
1416

1517
new SvelteDialog({
1618
id: DIALOG_ID,
@@ -22,6 +24,8 @@ export function openAnimationPropertiesDialog(animation: _Animation) {
2224
loopMode,
2325
loopDelay,
2426
excludedNodes,
27+
previewVariants,
28+
previewTextureSlots,
2529
},
2630
disableKeybinds: true,
2731
onConfirm() {
@@ -30,6 +34,9 @@ export function openAnimationPropertiesDialog(animation: _Animation) {
3034
animation.loop = loopMode.get() as any
3135
animation.loop_delay = loopDelay.get().toString()
3236
animation.excluded_nodes = excludedNodes.get()
37+
animation.preview_variants = previewVariants.get()
38+
animation.preview_texture_slots = previewTextureSlots.get()
39+
Animator.preview()
3340

3441
Project!.saved = false
3542
},

0 commit comments

Comments
 (0)