diff --git a/AnimationDirector.js b/AnimationDirector.js index c267a33..1f255fc 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -21,6 +21,8 @@ let packFoldersAction; let previousStagePoseAction; 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 PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews'; const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' }; @@ -2489,6 +2491,63 @@ 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() { if (!previousStagePoseAction) return; 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', variant: 'both', 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', { 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.', @@ -6073,6 +6148,8 @@ You can import or export complete AFW animation IDs as one file containing all s MenuBar.update(); }, onunload() { + removeStageAutoplay(); + autoplayStagesAction?.delete(); autoplayStagesAction = undefined; for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton); loopPoseAction?.delete(); loopPoseAction = undefined; for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton); diff --git a/test/block-requirements.test.js b/test/block-requirements.test.js index 610f929..8b3c3a2 100644 --- a/test/block-requirements.test.js +++ b/test/block-requirements.test.js @@ -48,6 +48,8 @@ function harness() { const context = { Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } }, 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 { constructor(id, options) { this.options = options; diff --git a/test/stage-autoplay.test.js b/test/stage-autoplay.test.js new file mode 100644 index 0000000..aefcad7 --- /dev/null +++ b/test/stage-autoplay.test.js @@ -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); +});