copy first keyframe button
This commit is contained in:
@@ -21,6 +21,8 @@
|
|||||||
let packFoldersAction;
|
let packFoldersAction;
|
||||||
let previousStagePoseAction;
|
let previousStagePoseAction;
|
||||||
let loopPoseAction;
|
let loopPoseAction;
|
||||||
|
let autoplayStagesAction, autoplayLoopOriginal, autoplayLoopWrapper;
|
||||||
|
let autoplayTransitioning = false;
|
||||||
const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation'];
|
const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation'];
|
||||||
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
|
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
|
||||||
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
|
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
|
||||||
@@ -2489,6 +2491,63 @@
|
|||||||
return { source: matches[0], previous };
|
return { source: matches[0], previous };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextAutoplayStage(animation) {
|
||||||
|
const current = parseAfwStageAnimationName(animation);
|
||||||
|
if (!current) return null;
|
||||||
|
const stages = Animation.all.map(parseAfwStageAnimationName)
|
||||||
|
.filter((stage) => stage?.id === current.id && stage.stageNumber > current.stageNumber)
|
||||||
|
.sort((a, b) => a.stageNumber - b.stageNumber);
|
||||||
|
if (!stages.length || stages.filter((stage) => stage.stageNumber === stages[0].stageNumber).length !== 1) return null;
|
||||||
|
return stages[0].animation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAutoplayIconColor() {
|
||||||
|
if (!autoplayStagesAction) return;
|
||||||
|
const color = autoplayStagesAction.value ? '#000000' : '#b8a6cc';
|
||||||
|
autoplayStagesAction.color = color;
|
||||||
|
if (autoplayStagesAction.icon_node) autoplayStagesAction.icon_node.style.color = color;
|
||||||
|
for (const node of autoplayStagesAction.nodes || []) {
|
||||||
|
const icon = node.querySelector('.icon');
|
||||||
|
if (icon) icon.style.color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installStageAutoplay() {
|
||||||
|
autoplayLoopOriginal = Timeline.loop;
|
||||||
|
// Native playback resets once-clips before emitting pause. Observe the completed
|
||||||
|
// playback step instead of treating a user's pause as a stage transition.
|
||||||
|
autoplayLoopWrapper = function (...args) {
|
||||||
|
// start() invokes loop() immediately. Defer that nested step to the next
|
||||||
|
// normal playback frame, but render the new pose synchronously below.
|
||||||
|
if (autoplayTransitioning) return;
|
||||||
|
const animation = Animation.selected;
|
||||||
|
const eligible = autoplayStagesAction?.value && Format.id === 'geckolib_model'
|
||||||
|
&& Timeline.playing && animation?.length > 0 && animation.loop !== 'loop'
|
||||||
|
&& !(Timeline.custom_range?.[0] || Timeline.custom_range?.[1]);
|
||||||
|
const result = autoplayLoopOriginal.apply(this, args);
|
||||||
|
if (eligible && autoplayStagesAction?.value && Animation.selected === animation && !Timeline.playing) {
|
||||||
|
const next = nextAutoplayStage(animation);
|
||||||
|
if (next) {
|
||||||
|
autoplayTransitioning = true;
|
||||||
|
try {
|
||||||
|
Timeline.setTime(0);
|
||||||
|
next.select();
|
||||||
|
Timeline.start();
|
||||||
|
Animator.preview(true);
|
||||||
|
} finally {
|
||||||
|
autoplayTransitioning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Timeline.loop = autoplayLoopWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStageAutoplay() {
|
||||||
|
if (Timeline.loop === autoplayLoopWrapper) Timeline.loop = autoplayLoopOriginal;
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPreviousStagePoseButton() {
|
function refreshPreviousStagePoseButton() {
|
||||||
if (!previousStagePoseAction) return;
|
if (!previousStagePoseAction) return;
|
||||||
const { reason } = previousStagePoseSource();
|
const { reason } = previousStagePoseSource();
|
||||||
@@ -5822,6 +5881,22 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
variant: 'both',
|
variant: 'both',
|
||||||
onload() {
|
onload() {
|
||||||
|
autoplayStagesAction = new Toggle('animationdirector_autoplay_stages', {
|
||||||
|
name: 'Autoplay Stages', icon: 'resume', color: '#b8a6cc', category: 'animation',
|
||||||
|
description: 'Automatically play the next stage when a non-looping stage finishes. Looping stages stay on their current clip.',
|
||||||
|
default: false, save_on_restart: true,
|
||||||
|
onChange: updateAutoplayIconColor,
|
||||||
|
condition: () => typeof Format !== 'undefined' && Format.id === 'geckolib_model',
|
||||||
|
});
|
||||||
|
if (typeof Toolbars !== 'undefined') {
|
||||||
|
const toolbar = Toolbars.animations;
|
||||||
|
if (toolbar) {
|
||||||
|
const length = toolbar.children.findIndex((item) => item === 'slider_animation_length' || item?.id === 'slider_animation_length');
|
||||||
|
toolbar.add(autoplayStagesAction, length >= 0 ? length + 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAutoplayIconColor();
|
||||||
|
installStageAutoplay();
|
||||||
previousStagePoseAction = new Action('animationdirector_previous_stage_pose', {
|
previousStagePoseAction = new Action('animationdirector_previous_stage_pose', {
|
||||||
name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation',
|
name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation',
|
||||||
description: 'Copy the final actor pose of the previous stage into time 0.',
|
description: 'Copy the final actor pose of the previous stage into time 0.',
|
||||||
@@ -6073,6 +6148,8 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
MenuBar.update();
|
MenuBar.update();
|
||||||
},
|
},
|
||||||
onunload() {
|
onunload() {
|
||||||
|
removeStageAutoplay();
|
||||||
|
autoplayStagesAction?.delete(); autoplayStagesAction = undefined;
|
||||||
for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton);
|
for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton);
|
||||||
loopPoseAction?.delete(); loopPoseAction = undefined;
|
loopPoseAction?.delete(); loopPoseAction = undefined;
|
||||||
for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton);
|
for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton);
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ function harness() {
|
|||||||
const context = {
|
const context = {
|
||||||
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
|
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
|
||||||
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
|
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
|
||||||
|
Toggle: class { constructor(id, options) { Object.assign(this, options, { id, value: false }); } },
|
||||||
|
Timeline: { loop() {} },
|
||||||
Panel: class {
|
Panel: class {
|
||||||
constructor(id, options) {
|
constructor(id, options) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const h = { Plugin: { register() {} }, Animation: { selected: null, all: [] }, Format: { id: 'geckolib_model' },
|
||||||
|
Animator: { preview() { h.rendered = h.Animation.selected; } },
|
||||||
|
Timeline: { playing: true, custom_range: [0, 0], loop() { this.playing = false; }, setTime(time) { this.time = time; }, start() { this.playing = true; this.loop(); } } };
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {',
|
||||||
|
' globalThis.api = { installStageAutoplay, removeStageAutoplay, nextAutoplayStage, setEnabled(value) { autoplayStagesAction = { value }; } };\n Plugin.register(PLUGIN_ID, {');
|
||||||
|
vm.runInNewContext(source, h);
|
||||||
|
h.clip = (name, loop = 'once') => ({ name, length: 2, loop, select() { h.Animation.selected = this; } });
|
||||||
|
h.api.setEnabled(true); h.api.installStageAutoplay();
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('autoplay advances completed non-looping stages by number within the same ID', () => {
|
||||||
|
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
|
||||||
|
h.Animation.selected = first; h.Animation.all = [h.clip('other.p2'), h.clip('example.p10'), next, first];
|
||||||
|
h.Timeline.loop();
|
||||||
|
assert.equal(h.Animation.selected, next);
|
||||||
|
assert.equal(h.Timeline.time, 0);
|
||||||
|
assert.equal(h.Timeline.playing, true);
|
||||||
|
assert.equal(h.rendered, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoplay leaves looping stages, custom ranges, disabled toggle and final stage alone', () => {
|
||||||
|
for (const mode of ['loop', 'range', 'disabled', 'final']) {
|
||||||
|
const h = harness(), first = h.clip('example.p1', mode === 'loop' ? 'loop' : 'hold');
|
||||||
|
h.Animation.selected = first; h.Animation.all = mode === 'final' ? [first] : [first, h.clip('example.p2')];
|
||||||
|
if (mode === 'range') h.Timeline.custom_range = [0, 1];
|
||||||
|
if (mode === 'disabled') h.api.setEnabled(false);
|
||||||
|
h.Timeline.loop();
|
||||||
|
assert.equal(h.pending, undefined);
|
||||||
|
assert.equal(h.Animation.selected, first);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual playback stops do not advance and unload restores playback', () => {
|
||||||
|
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
|
||||||
|
h.Animation.selected = first; h.Animation.all = [first, next];
|
||||||
|
h.Timeline.playing = false; h.Timeline.loop();
|
||||||
|
assert.equal(h.pending, undefined);
|
||||||
|
assert.equal(h.Animation.selected, first);
|
||||||
|
assert.equal(h.Timeline.playing, false);
|
||||||
|
const wrapper = h.Timeline.loop; h.api.removeStageAutoplay();
|
||||||
|
assert.notEqual(h.Timeline.loop, wrapper);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user