From 55d39d3030cad6b9257748dc9067d4b0a7309d91 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:08:06 +0200 Subject: [PATCH 01/44] feat: slot animations, registries and registry-backed inventory reloads Slot animations - Reveal/conceal animations for inventories (open, close, actions), per-button settings, idle animations, temporary items with keyframes, variants, seeds. - Built-in reveal orders in api.animation.slot.order; each type is a SlotAnimationLoader registered through @AutoSlotAnimationLoader, addons use InventoryManager#registerSlotAnimation. - The compiler is internal (Common fr.maxlego08.menu.animation.compile); the API keeps the entry points, settings and order implementations. Registries - Typed registries (RegistryType, KeyedRegistry, WritableRegistry, RegistryManager) with namespaced keys, owners, aliases, and static registries frozen once the server has loaded. - Reloadable registries rebuilt into a snapshot and swapped atomically (ReloadContext, ReloadReport, ReloadValidator). Inventories - zMenu's own menus live in a reloadable registry keyed zmenu:: a file that fails on reload keeps its previous version, load events fire once the new snapshot is live, YAML errors no longer load an empty menu. - Key-based InventoryManager methods; the name-based ones are deprecated for removal and delegate to them. --- API/build.gradle.kts | 10 + .../java/fr/maxlego08/menu/api/Inventory.java | 19 +- .../maxlego08/menu/api/InventoryManager.java | 231 +++- .../java/fr/maxlego08/menu/api/ZMenuKeys.java | 99 ++ .../menu/api/animation/slot/Animation.java | 16 + .../api/animation/slot/AnimationContext.java | 135 +++ .../api/animation/slot/AnimationManager.java | 97 ++ .../api/animation/slot/AnimationPlayMode.java | 94 ++ .../api/animation/slot/AnimationPlayback.java | 62 + .../api/animation/slot/AnimationSlots.java | 82 ++ .../api/animation/slot/CustomAnimation.java | 350 ++++++ .../api/animation/slot/SlotAnimation.java | 43 + .../slot/order/CheckerboardSlotAnimation.java | 26 + .../slot/order/ColumnsSlotAnimation.java | 27 + .../slot/order/CustomSlotAnimation.java | 51 + .../slot/order/FromCenterSlotAnimation.java | 33 + .../order/HorizontalSnakeSlotAnimation.java | 29 + .../slot/order/MatrixSlotAnimation.java | 61 + .../slot/order/RandomSlotAnimation.java | 36 + .../slot/order/SequentialSlotAnimation.java | 17 + .../api/animation/slot/order/SlotGrid.java | 39 + .../slot/order/SpiralSlotAnimation.java | 50 + .../order/VerticalSnakeSlotAnimation.java | 30 + .../slot/order/WaveSlotAnimation.java | 25 + .../slot/settings/AnimationAnchor.java | 169 +++ .../slot/settings/AnimationSeed.java | 92 ++ .../slot/settings/AnimationVariant.java | 21 + .../settings/ButtonAnimationSettings.java | 49 + .../slot/settings/SlotAnimationGroup.java | 47 + .../slot/settings/SlotAnimationSettings.java | 117 ++ .../settings/idle/ButtonIdleAnimation.java | 127 +++ .../slot/settings/idle/IdleFrame.java | 44 + .../slot/settings/item/AnimationKeyframe.java | 45 + .../slot/settings/item/ItemChoice.java | 22 + .../slot/settings/item/TemporaryItem.java | 53 + .../annotations/AutoSlotAnimationLoader.java | 17 + .../fr/maxlego08/menu/api/button/Button.java | 64 ++ .../api/button/buttons/ItemDragButton.java | 13 + .../menu/api/configuration/Configuration.java | 24 + .../menu/api/engine/BaseInventory.java | 10 + .../menu/api/engine/InventoryEngine.java | 59 + .../maxlego08/menu/api/engine/OpenCause.java | 21 + .../api/event/events/AnimationEndEvent.java | 65 ++ .../api/event/events/AnimationStartEvent.java | 74 ++ .../api/inventory/ContainerInventory.java | 35 + .../animation/slot/SlotAnimationLoader.java | 70 ++ .../menu/api/registry/KeyedRegistry.java | 54 + .../api/registry/RegistrationException.java | 14 + .../menu/api/registry/Registries.java | 14 + .../maxlego08/menu/api/registry/Registry.java | 5 + .../menu/api/registry/RegistryAccess.java | 21 + .../menu/api/registry/RegistryKey.java | 23 + .../menu/api/registry/RegistryManager.java | 66 ++ .../menu/api/registry/RegistryType.java | 32 + .../menu/api/registry/WritableRegistry.java | 44 + .../api/registry/reload/ReloadContext.java | 50 + .../api/registry/reload/ReloadReport.java | 60 + .../api/registry/reload/ReloadReporter.java | 30 + .../api/registry/reload/ReloadValidator.java | 23 + .../registry/reload/ReloadableProvider.java | 16 + .../registry/reload/ReloadableRegistry.java | 19 + .../menu/api/storage/StorageManager.java | 32 + .../fr/maxlego08/menu/api/storage/Tables.java | 1 + .../api/storage/dto/PlayerAnimationDTO.java | 13 + .../menu/api/utils/InventoryReplacement.java | 9 +- .../fr/maxlego08/menu/api/utils/Message.java | 3 + .../animation/slot/AnimationPlayModeTest.java | 107 ++ .../animation/slot/AnimationSlotsTest.java | 63 ++ .../slot/order/CustomSlotAnimationTest.java | 53 + .../slot/order/MatrixSlotAnimationTest.java | 67 ++ .../slot/order/SlotAnimationTest.java | 150 +++ .../slot/settings/AnimationAnchorTest.java | 83 ++ .../slot/settings/AnimationSeedTest.java | 80 ++ .../idle/ButtonIdleAnimationTest.java | 111 ++ .../api/button/ButtonTemporaryItemsTest.java | 80 ++ .../animation/slot/TestMenuItemStacks.java | 27 + .../menu/api/animation/slot/TestSounds.java | 32 + Common/build.gradle.kts | 2 + .../fr/maxlego08/menu/ZMenuItemStack.java | 48 +- .../animation/compile/AnimationCompiler.java | 369 ++++++ .../menu/animation/compile/AnimationStep.java | 42 + .../animation/compile/AnimationTiming.java | 64 ++ .../animation/compile/CompiledAnimation.java | 42 + .../menu/animation/compile/GroupTiming.java | 10 + .../menu/animation/compile/StepAction.java | 24 + .../menu/common/enums/Permission.java | 3 +- .../common/utils/cache/YamlFileCache.java | 30 + .../maxlego08/menu/inventory/VInventory.java | 16 + .../inventories/InventoryDefault.java | 873 +++++++++++++- .../setter/ContainerInventorySetter.java | 7 + .../menu/inventory/zinv/ZInventory.java | 34 + .../menu/registry/SimpleRegistry.java | 172 +++ .../menu/registry/SnapshotEditor.java | 23 + .../menu/registry/SnapshotRegistry.java | 101 ++ .../menu/registry/ZRegistryManager.java | 297 +++++ .../compile/AnimationActionsTest.java | 96 ++ .../compile/AnimationChanceTest.java | 138 +++ .../compile/AnimationCompilerTest.java | 214 ++++ .../compile/AnimationJitterTest.java | 88 ++ .../compile/AnimationRequirementTest.java | 81 ++ .../animation/compile/AnimationSoundTest.java | 94 ++ .../compile/AnimationVariantTest.java | 140 +++ .../compile/ConcealCompilerTest.java | 142 +++ .../compile/CustomAnimationTest.java | 195 ++++ .../animation/compile/ItemChoiceTest.java | 133 +++ .../compile/KeyframeCompilerTest.java | 158 +++ .../compile/OverlayCompilerTest.java | 105 ++ .../compile/TemporaryItemCompilerTest.java | 224 ++++ .../menu/registry/ReloadableRegistryTest.java | 274 +++++ .../menu/registry/SimpleRegistryTest.java | 135 +++ .../menu/hooks/dialogs/ZDialogManager.java | 3 +- Test/v26_1_2/build.gradle.kts | 5 + .../maxlego08/menu/InventoryRegistryTest.java | 179 +++ .../menu/SlotAnimationLoaderTest.java | 68 ++ docs/animations-api.md | 247 ++++ docs/slot-animations.md | 1003 +++++++++++++++++ .../fr/maxlego08/menu/ZInventoryManager.java | 519 ++++++--- .../java/fr/maxlego08/menu/ZMenuPlugin.java | 21 + .../menu/animation/ZAnimationManager.java | 150 +++ .../menu/button/buttons/ZInventoryButton.java | 3 +- .../menu/button/buttons/ZMainMenuButton.java | 4 +- .../fr/maxlego08/menu/command/VCommand.java | 13 +- .../command/commands/CommandInventory.java | 6 +- .../menu/command/commands/CommandMenu.java | 1 + .../commands/CommandMenuAnimationReset.java | 58 + .../commands/CommandMenuGiveOpenItem.java | 3 +- .../command/commands/CommandMenuOpen.java | 3 +- .../commands/CommandMenuOpenMainMenu.java | 2 +- .../commands/reload/CommandMenuReload.java | 2 +- .../reload/CommandMenuReloadCommand.java | 3 +- .../reload/CommandMenuReloadConfig.java | 1 + .../reload/CommandMenuReloadInventory.java | 4 +- .../menu/inventory/VInventoryManager.java | 31 +- .../menu/listener/RegistryFreezeListener.java | 25 + .../menu/listener/SwapKeyListener.java | 6 +- .../menu/loader/InventoryLoader.java | 126 ++- .../loader/SlotAnimationSettingsLoader.java | 645 +++++++++++ .../maxlego08/menu/loader/ZButtonLoader.java | 4 + .../loader/actions/AnimationActionLoader.java | 76 ++ .../CheckerboardSlotAnimationLoader.java | 28 + .../ColumnsSlotAnimationLoader.java | 28 + .../animations/CustomSlotAnimationLoader.java | 43 + .../FromCenterSlotAnimationLoader.java | 28 + .../HorizontalSnakeSlotAnimationLoader.java | 28 + .../animations/MatrixSlotAnimationLoader.java | 37 + .../animations/RandomSlotAnimationLoader.java | 28 + .../SequentialSlotAnimationLoader.java | 28 + .../animations/SpiralSlotAnimationLoader.java | 28 + .../VerticalSnakeSlotAnimationLoader.java | 28 + .../animations/WaveSlotAnimationLoader.java | 28 + .../requirement/actions/AnimationAction.java | 69 ++ .../menu/requirement/actions/BackAction.java | 4 +- .../menu/requirement/actions/CloseAction.java | 4 +- .../requirement/actions/InventoryAction.java | 7 +- .../menu/sound/RandomPitchSoundOption.java | 64 ++ .../menu/storage/ZStorageManager.java | 36 + .../migrations/PlayerAnimationMigration.java | 16 + src/main/resources/animations/fancy_open.yml | 40 + src/main/resources/animations/glint.yml | 42 + src/main/resources/animations/heartbeat.yml | 56 + src/main/resources/animations/pulse.yml | 47 + src/main/resources/animations/sparkle.yml | 41 + src/main/resources/animations/wave_in.yml | 23 + src/main/resources/config.yml | 12 + .../inventories/examples/animation.yml | 91 ++ .../inventories/examples/animation_action.yml | 116 ++ .../examples/animation_advanced.yml | 249 ++++ .../inventories/examples/animation_close.yml | 100 ++ .../inventories/examples/animation_custom.yml | 87 ++ .../inventories/examples/animation_idle.yml | 161 +++ .../examples/animation_loading.yml | 107 ++ .../inventories/examples/animation_matrix.yml | 97 ++ .../inventories/examples/animation_random.yml | 177 +++ .../inventories/examples/animation_shared.yml | 69 ++ .../examples/animation_variants.yml | 108 ++ .../inventories/examples/hub_menu.yml | 226 ++++ 176 files changed, 14020 insertions(+), 228 deletions(-) create mode 100644 API/src/main/java/fr/maxlego08/menu/api/ZMenuKeys.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/Animation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationContext.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationManager.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayMode.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayback.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationSlots.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/CustomAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/SlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CheckerboardSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/ColumnsSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/FromCenterSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/HorizontalSnakeSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/RandomSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SequentialSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SlotGrid.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SpiralSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/VerticalSnakeSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/WaveSlotAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchor.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeed.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationVariant.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/ButtonAnimationSettings.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationGroup.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationSettings.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimation.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/IdleFrame.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/AnimationKeyframe.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/ItemChoice.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/TemporaryItem.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/annotations/AutoSlotAnimationLoader.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/engine/OpenCause.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationEndEvent.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationStartEvent.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/loader/animation/slot/SlotAnimationLoader.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/KeyedRegistry.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/RegistrationException.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/Registries.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/RegistryAccess.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/RegistryKey.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/RegistryManager.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/RegistryType.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/WritableRegistry.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadContext.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReport.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReporter.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadValidator.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableProvider.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableRegistry.java create mode 100644 API/src/main/java/fr/maxlego08/menu/api/storage/dto/PlayerAnimationDTO.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayModeTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationSlotsTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimationTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimationTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/SlotAnimationTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchorTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeedTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimationTest.java create mode 100644 API/src/test/java/fr/maxlego08/menu/api/button/ButtonTemporaryItemsTest.java create mode 100644 API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestMenuItemStacks.java create mode 100644 API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestSounds.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationCompiler.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationStep.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationTiming.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/animation/compile/CompiledAnimation.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/animation/compile/GroupTiming.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/animation/compile/StepAction.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/registry/SimpleRegistry.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/registry/SnapshotEditor.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/registry/SnapshotRegistry.java create mode 100644 Common/src/main/java/fr/maxlego08/menu/registry/ZRegistryManager.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/AnimationActionsTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/AnimationChanceTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/AnimationCompilerTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/AnimationJitterTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/AnimationRequirementTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/AnimationSoundTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/AnimationVariantTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/ConcealCompilerTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/CustomAnimationTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/ItemChoiceTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/KeyframeCompilerTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/OverlayCompilerTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/animation/compile/TemporaryItemCompilerTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/registry/ReloadableRegistryTest.java create mode 100644 Common/src/test/java/fr/maxlego08/menu/registry/SimpleRegistryTest.java create mode 100644 Test/v26_1_2/src/test/java/fr/maxlego08/menu/InventoryRegistryTest.java create mode 100644 Test/v26_1_2/src/test/java/fr/maxlego08/menu/SlotAnimationLoaderTest.java create mode 100644 docs/animations-api.md create mode 100644 docs/slot-animations.md create mode 100644 src/main/java/fr/maxlego08/menu/animation/ZAnimationManager.java create mode 100644 src/main/java/fr/maxlego08/menu/command/commands/CommandMenuAnimationReset.java create mode 100644 src/main/java/fr/maxlego08/menu/listener/RegistryFreezeListener.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/SlotAnimationSettingsLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/actions/AnimationActionLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/CheckerboardSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/ColumnsSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/CustomSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/FromCenterSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/HorizontalSnakeSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/MatrixSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/RandomSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/SequentialSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/SpiralSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/VerticalSnakeSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/loader/animations/WaveSlotAnimationLoader.java create mode 100644 src/main/java/fr/maxlego08/menu/requirement/actions/AnimationAction.java create mode 100644 src/main/java/fr/maxlego08/menu/sound/RandomPitchSoundOption.java create mode 100644 src/main/java/fr/maxlego08/menu/storage/migrations/PlayerAnimationMigration.java create mode 100644 src/main/resources/animations/fancy_open.yml create mode 100644 src/main/resources/animations/glint.yml create mode 100644 src/main/resources/animations/heartbeat.yml create mode 100644 src/main/resources/animations/pulse.yml create mode 100644 src/main/resources/animations/sparkle.yml create mode 100644 src/main/resources/animations/wave_in.yml create mode 100644 src/main/resources/inventories/examples/animation.yml create mode 100644 src/main/resources/inventories/examples/animation_action.yml create mode 100644 src/main/resources/inventories/examples/animation_advanced.yml create mode 100644 src/main/resources/inventories/examples/animation_close.yml create mode 100644 src/main/resources/inventories/examples/animation_custom.yml create mode 100644 src/main/resources/inventories/examples/animation_idle.yml create mode 100644 src/main/resources/inventories/examples/animation_loading.yml create mode 100644 src/main/resources/inventories/examples/animation_matrix.yml create mode 100644 src/main/resources/inventories/examples/animation_random.yml create mode 100644 src/main/resources/inventories/examples/animation_shared.yml create mode 100644 src/main/resources/inventories/examples/animation_variants.yml create mode 100644 src/main/resources/inventories/examples/hub_menu.yml diff --git a/API/build.gradle.kts b/API/build.gradle.kts index bccc0c4d0..d757493f1 100644 --- a/API/build.gradle.kts +++ b/API/build.gradle.kts @@ -1,10 +1,20 @@ plugins { alias(libs.plugins.groupez.publish) + `java-test-fixtures` } dependencies{ compileOnly(libs.adventure.api) compileOnly(libs.adventure.text.minimessage) + + testImplementation(libs.paper.api) + testFixturesCompileOnly(libs.paper.api) +} + +// The test fixtures are shared with the tests of other modules, they are not published. +(components["java"] as AdhocComponentWithVariants).apply { + withVariantsFromConfiguration(configurations["testFixturesApiElements"]) { skip() } + withVariantsFromConfiguration(configurations["testFixturesRuntimeElements"]) { skip() } } rootProject.extra.properties["sha"]?.let { sha -> diff --git a/API/src/main/java/fr/maxlego08/menu/api/Inventory.java b/API/src/main/java/fr/maxlego08/menu/api/Inventory.java index 3ba01bd93..68db976fb 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/Inventory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/Inventory.java @@ -12,6 +12,8 @@ import fr.maxlego08.menu.api.utils.InventoryReplacement; import fr.maxlego08.menu.api.utils.OpenWithItem; import fr.maxlego08.menu.api.utils.Placeholders; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryType; import org.bukkit.plugin.Plugin; @@ -29,7 +31,19 @@ * Documentation: Inventory documentation *

This class encapsulates information about an inventory, including its size, buttons, patterns, and associated requirements.

*/ -public interface Inventory { +public interface Inventory extends Keyed { + + /** + * Returns the key of the inventory: the name of its plugin and its file name, lowercased, with + * every character a key cannot hold replaced by "_". + * + * @return the key + * @see ZMenuKeys#fileKey(String, String) + */ + @Override + default @NotNull Key key() { + return ZMenuKeys.fileKey(this.getPlugin().getName(), this.getFileName()); + } /** * Returns the size of the inventory. @@ -89,6 +103,9 @@ public interface Inventory { /** * Returns the name of the file associated with the inventory. * + *

Use it to display or store the inventory, not to identify it: the case is kept, so it can + * differ from {@link #key()}. Compare and look up inventories with {@link #key()}.

+ * * @return The file name. This name is the name of the configuration file. */ String getFileName(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java b/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java index 0846af5a5..90aabb7ef 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/InventoryManager.java @@ -1,6 +1,7 @@ package fr.maxlego08.menu.api; import com.tcoded.folialib.impl.PlatformScheduler; +import fr.maxlego08.menu.api.animation.slot.AnimationManager; import fr.maxlego08.menu.api.button.Button; import fr.maxlego08.menu.api.button.ButtonOption; import fr.maxlego08.menu.api.button.GenericPaginationButton; @@ -13,10 +14,15 @@ import fr.maxlego08.menu.api.font.FontImage; import fr.maxlego08.menu.api.itemstack.ItemStackSimilar; import fr.maxlego08.menu.api.loader.MaterialLoader; +import fr.maxlego08.menu.api.loader.animation.slot.SlotAnimationLoader; import fr.maxlego08.menu.api.pagination.PaginationManager; +import fr.maxlego08.menu.api.registry.Registries; +import fr.maxlego08.menu.api.registry.RegistryManager; +import fr.maxlego08.menu.api.registry.WritableRegistry; import fr.maxlego08.menu.api.utils.Message; import fr.maxlego08.menu.api.utils.MetaUpdater; import fr.maxlego08.menu.api.utils.Placeholders; +import net.kyori.adventure.key.Key; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.configuration.file.YamlConfiguration; @@ -26,6 +32,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.File; import java.util.*; @@ -112,12 +119,33 @@ public interface InventoryManager extends Listener { */ Inventory loadInventory(Plugin plugin, File file) throws InventoryException; + /** + * Returns an inventory by its key, {@code plugin:file}. + * + *

Build the key with {@link ZMenuKeys#fileKey(String, String)}, or read a reference written + * by a user with {@link #findInventory(String)}.

+ * + * @param key the key of the inventory + * @return Optional of {@link Inventory}. + */ + Optional getInventory(@NotNull Key key); + + /** + * Returns the keys of every loaded inventory. + * + * @return the keys, {@code plugin:file} + */ + Set getInventoryKeys(); + /** * Returns an optional of {@link Inventory} based on its name. * * @param name Inventory name. * @return Optional of {@link Inventory}. + * @deprecated a file name and the key of its inventory can differ (case, spaces). Use + * {@link #getInventory(Key)}, or {@link #findInventory(String)} for a name written by a user. */ + @Deprecated(forRemoval = true) Optional getInventory(String name); /** @@ -126,7 +154,9 @@ public interface InventoryManager extends Listener { * @param plugin The plugin where the inventory comes from. * @param name Inventory name. * @return Optional of {@link Inventory}. + * @deprecated use {@link #getInventory(Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(forRemoval = true) Optional getInventory(Plugin plugin, String name); /** @@ -135,7 +165,9 @@ public interface InventoryManager extends Listener { * @param pluginName The plugin name where the inventory comes from. * @param name Inventory name. * @return Optional of {@link Inventory}. + * @deprecated use {@link #getInventory(Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(forRemoval = true) Optional getInventory(String pluginName, String name); /** @@ -153,6 +185,15 @@ public interface InventoryManager extends Listener { */ Collection getInventories(Plugin plugin); + /** + * Finds an inventory from a reference written by a user, in a file or a command: + * {@code plugin:file} is looked up exactly, while a plain {@code file} is looked up in zMenu + * first, then in the other plugins. + * + * @param inventoryName the reference + * @return Optional of {@link Inventory}. + * @see ZMenuKeys#inventoryKey(String) + */ Optional findInventory(@NotNull String inventoryName); /** @@ -162,12 +203,22 @@ public interface InventoryManager extends Listener { */ void deleteInventory(Inventory inventory); + /** + * Deletes an inventory by its key. + * + * @param key the key of the inventory + * @return True if the inventory has been deleted. + */ + boolean deleteInventory(@NotNull Key key); + /** * Deletes an inventory based on its name. * * @param name Inventory name. * @return True if the inventory has been deleted. + * @deprecated use {@link #deleteInventory(Key)}. */ + @Deprecated(forRemoval = true) boolean deleteInventory(String name); /** @@ -177,6 +228,11 @@ public interface InventoryManager extends Listener { */ void deleteInventories(Plugin plugin); + /** + * @return the inventories as {@code plugin:file}, lowercased + * @deprecated use {@link #getInventoryKeys()}. + */ + @Deprecated(forRemoval = true) Set getInventoryNames(); /** @@ -259,13 +315,33 @@ public interface InventoryManager extends Listener { */ Collection getMaterialLoader(); + /** + * Opens an inventory by its key. When it does not exist, the player's inventory is closed and + * they are told so. + * + * @param player Player who will open the inventory. + * @param key the key of the inventory + */ + void openInventory(Player player, @NotNull Key key); + + /** + * Opens an inventory by its key, on a page. + * + * @param player Player who will open the inventory. + * @param key the key of the inventory + * @param page The page number of the inventory to open. + */ + void openInventory(Player player, @NotNull Key key, int page); + /** * Opens an inventory based on its name and the plugin. * * @param player Player who will open the inventory. * @param plugin The plugin where the inventory comes from. * @param inventoryName Name of the inventory to be opened. + * @deprecated use {@link #openInventory(Player, Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(forRemoval = true) void openInventory(Player player, Plugin plugin, String inventoryName); /** @@ -274,7 +350,9 @@ public interface InventoryManager extends Listener { * @param player Player who will open the inventory. * @param pluginName The plugin where the inventory comes from. * @param inventoryName Name of the inventory to be opened. + * @deprecated use {@link #openInventory(Player, Key)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(forRemoval = true) void openInventory(Player player, String pluginName, String inventoryName); /** @@ -284,28 +362,33 @@ public interface InventoryManager extends Listener { * @param pluginName The plugin where the inventory comes from. * @param inventoryName Name of the inventory to be opened. * @param page The page number of the inventory to open. + * @deprecated use {@link #openInventory(Player, Key, int)} with {@link ZMenuKeys#fileKey(String, String)}. */ + @Deprecated(forRemoval = true) void openInventory(Player player, String pluginName, String inventoryName, int page); /** * Opens an inventory based on its name. Note that - * this method searches in all inventories; it is more appropriate to use - * the method {@link #openInventory(Player, Plugin, String)}. + * this method searches in all inventories. * * @param player Player who will open the inventory. * @param inventoryName Name of the inventory to be opened. + * @deprecated use {@link #openInventory(Player, Key)}, or {@link #findInventory(String)} for a + * name written by a user. */ + @Deprecated(forRemoval = true) void openInventory(Player player, String inventoryName); /** * Opens an inventory based on its name and a specific page number. - * This method allows a player to view a particular page of an inventory, - * facilitating navigation through multi-page inventories. * * @param player Player who will open the inventory. * @param inventoryName Name of the inventory to be opened. * @param page The page number of the inventory to open. + * @deprecated use {@link #openInventory(Player, Key, int)}, or {@link #findInventory(String)} + * for a name written by a user. */ + @Deprecated(forRemoval = true) void openInventory(Player player, String inventoryName, int page); /** @@ -625,4 +708,144 @@ public interface InventoryManager extends Listener { */ PaginationManager getPaginationManager(); + /** + * Gets the manager holding the animations of the {@code animations} folder. + * + *

Use it to fetch an animation by name, or to register one of your own:

+ * + *
+     * inventoryManager.getAnimationManager().registerAnimation(new Animation("my_animation", settings));
+     * 
+ * + * @return The animation manager. + * @see AnimationManager + */ + AnimationManager getAnimationManager(); + + /** + * Gets every registry of zMenu, such as the animations an inventory may use as its {@code type}. + * + *

Read an entry, and register your own from your {@code onEnable}:

+ * + *
+     * inventoryManager.getRegistries().registry(Registries.SLOT_ANIMATION).get(ZMenuKeys.key("spiral"));
+     * inventoryManager.getRegistries().writable(Registries.MY_REGISTRY).register(plugin, myEntry);
+     * 
+ * + *

Static registries stop accepting entries once the server has finished loading.

+ * + * @return The registries. + * @see fr.maxlego08.menu.api.registry.Registries + */ + RegistryManager getRegistries(); + + /** + * Registers an animation menus may use as their {@code type}, under its key and its aliases. + * Call it from your {@code onEnable}: + * + *
+     * inventoryManager.registerSlotAnimation(plugin, new MyAnimationLoader());
+     * 
+ * + * @param plugin the plugin registering it, shown when two animations share a name + * @param loader the loader + * @throws fr.maxlego08.menu.api.registry.RegistrationException if a name is taken, or the server has finished loading + */ + default void registerSlotAnimation(@NotNull Plugin plugin, @NotNull SlotAnimationLoader loader) { + WritableRegistry registry = this.getRegistries().writable(Registries.SLOT_ANIMATION); + registry.register(plugin, loader); + for (Key alias : loader.aliases()) { + registry.alias(plugin.getName(), alias, loader.key()); + } + } + + /** + * Returns whether the opening animation of an inventory has already been played for a player + * since they connected. + * + *

Only used by {@link fr.maxlego08.menu.api.animation.slot.AnimationPlayMode#ONCE}.

+ * + * @param player The player. + * @param animationKey The key identifying the inventory. + * @return true if the animation has already been played. + */ + default boolean hasPlayedAnimation(@NotNull Player player, @NotNull String animationKey) { + return false; + } + + /** + * Remembers that the opening animation of an inventory has been played for a player. + * + * @param player The player. + * @param animationKey The key identifying the inventory. + */ + default void markAnimationPlayed(@NotNull Player player, @NotNull String animationKey) { + } + + /** + * Forgets every animation played for a player, when they disconnect. + * + * @param uniqueId The unique id of the player. + */ + default void clearPlayedAnimations(@NotNull UUID uniqueId) { + } + + /** + * Reads back which animations a player has already seen, when they join. + * + *

Called once per connection. The rows are fetched off the main thread and kept in memory, + * so that opening a menu never waits on the database.

+ * + * @param player The player. + */ + default void loadAnimationHistory(@NotNull Player player) { + } + + /** + * When a player last saw an animation that remembers itself beyond the session. + * + *

Answered from memory: the rows are read once when the player joins, because a menu opens + * far too often to ask the database each time.

+ * + * @param player The player. + * @param animationKey The key identifying the inventory. + * @return the time it last played in milliseconds since the epoch, or null if it never has. + */ + @Nullable + default Long getAnimationPlayedAt(@NotNull Player player, @NotNull String animationKey) { + return null; + } + + /** + * Whether what a player has already seen is known yet. + * + *

False for the short moment between joining and the rows coming back, during which an + * animation that only plays once is held back rather than risking spending its single play.

+ * + * @param player The player. + * @return true once the player's history is in memory. + */ + default boolean isAnimationHistoryLoaded(@NotNull Player player) { + return true; + } + + /** + * Remembers that an animation has played, in the database as well as in memory. + * + * @param player The player. + * @param animationKey The key identifying the inventory. + * @param playedAt When it played, in milliseconds since the epoch. + */ + default void markAnimationPlayedForever(@NotNull Player player, @NotNull String animationKey, long playedAt) { + } + + /** + * Forgets that a player has seen an animation, so one that only plays once plays again. + * + * @param uniqueId The unique id of the player. + * @param animationKey The animation to forget, or null to forget all of them. + */ + default void forgetPlayedAnimation(@NotNull UUID uniqueId, @Nullable String animationKey) { + } + } \ No newline at end of file diff --git a/API/src/main/java/fr/maxlego08/menu/api/ZMenuKeys.java b/API/src/main/java/fr/maxlego08/menu/api/ZMenuKeys.java new file mode 100644 index 000000000..415ea3550 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/ZMenuKeys.java @@ -0,0 +1,99 @@ +package fr.maxlego08.menu.api; + +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; + +/** + * A class that provides utility methods for creating namespaced keys with zmenu as the namespace. + * This class cannot be instantiated. + */ +public final class ZMenuKeys { + public static final String NAMESPACE = "zmenu"; + + /** + * The constructor is private to prevent instantiation. + */ + private ZMenuKeys() { + } + + /** + * Creates a namespaced key with the namespace {@link #NAMESPACE} and the given value. + * + * @param value the value of the key + * @return a namespaced key with the namespace {@link #NAMESPACE} and the given value + */ + public static @NotNull Key key(@NotNull String value) { + return Key.key(NAMESPACE, value); + } + + /** + * Gets the key of something read from a file, such as an inventory. Unlike + * {@link #parse(String)}, "-" stays "-", so {@code my-shop} and {@code my_shop} stay apart. + * + * @param namespace usually the name of the plugin that owns the file + * @param fileName the name of the file, without its extension + * @return the key + */ + public static @NotNull Key fileKey(@NotNull String namespace, @NotNull String fileName) { + return Key.key(sanitize(namespace), sanitize(fileName)); + } + + /** + * Reads a reference to an inventory written in a file or a command: {@code plugin:file} or + * {@code file}, which belongs to {@link #NAMESPACE}. + * + * @param reference the reference + * @return the key of the inventory + */ + public static @NotNull Key inventoryKey(@NotNull String reference) { + int separator = reference.indexOf(':'); + return separator < 0 ? fileKey(NAMESPACE, reference) : fileKey(reference.substring(0, separator), reference.substring(separator + 1)); + } + + /** + * Tells whether a reference names this key. {@code plugin:file} must match exactly, while a + * plain {@code file} matches that file name in any namespace, as name lookups always did. + * + * @param key the key of an inventory + * @param reference the reference + * @return whether they match + */ + public static boolean matches(@NotNull Key key, @NotNull String reference) { + Key referenced = inventoryKey(reference); + return reference.indexOf(':') < 0 ? key.value().equals(referenced.value()) : key.equals(referenced); + } + + /** + * Lowercases a name and replaces every character a key cannot hold with "_", so that a file + * named {@code My Shop} is {@code my_shop}. + */ + private static String sanitize(String name) { + String lower = name.trim().toLowerCase(Locale.ROOT); + StringBuilder builder = new StringBuilder(lower.length()); + for (int index = 0; index < lower.length(); index++) { + char character = lower.charAt(index); + boolean valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '_' || character == '-' || character == '.'; + builder.append(valid ? character : '_'); + } + return builder.isEmpty() ? "_" : builder.toString(); + } + + /** + * Reads a key written in a file or a command. The case is ignored and "-" is read as "_". + * Without a namespace, the key belongs to {@link #NAMESPACE}: {@code spiral} is + * {@code zmenu:spiral}, while {@code myplugin:spiral} keeps its own namespace. + * + * @param input the text to read + * @return the key + * @throws net.kyori.adventure.key.InvalidKeyException if the text is not a valid key + */ + public static @NotNull Key parse(@NotNull String input) { + String text = input.trim().toLowerCase(Locale.ROOT); + int separator = text.indexOf(':'); + String namespace = separator < 0 ? NAMESPACE : text.substring(0, separator); + String value = text.substring(separator + 1).replace('-', '_'); + return Key.key(namespace, value); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/Animation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/Animation.java new file mode 100644 index 000000000..8b2dd4309 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/Animation.java @@ -0,0 +1,16 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import org.jetbrains.annotations.NotNull; + +/** + * A named animation, loaded from the {@code animations} folder or registered by a plugin. + * + *

An inventory uses one with {@code open-animation: {file: name}}, and anything it writes next to + * {@code file} overrides the shared settings key by key.

+ * + * @param name the name the animation is known by, which is its file name without the extension + * @param settings what the animation does + */ +public record Animation(@NotNull String name, @NotNull SlotAnimationSettings settings) { +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationContext.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationContext.java new file mode 100644 index 000000000..a0bf49c2b --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationContext.java @@ -0,0 +1,135 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationVariant; +import fr.maxlego08.menu.api.animation.slot.settings.item.ItemChoice; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +/** + * What a {@link SlotAnimation} is being asked to order, and who for. + * + *

The player and the inventory are known when an animation is compiled but not when it is + * written, so an ordering may depend on them: a different order per player, a seed that stays the + * same for one player, or an order that reads something from the menu itself.

+ * + *

They are null when an animation is compiled outside of an inventory, such as in a test, so an + * implementation using them must cope with their absence.

+ * + * @param inventorySize the number of slots the animation is laid out over: the menu and the player + * inventory together, not the size of the menu alone. See {@link AnimationSlots} + * @param slots the slots to order + * @param player the player the animation is played for, if there is one + * @param inventory the menu being animated, if there is one + * @param random the source every roll of the animation is taken from + */ +public record AnimationContext( + int inventorySize, + @NotNull List slots, + @Nullable Player player, + @Nullable Inventory inventory, + @NotNull Random random +) { + + public AnimationContext { + slots = List.copyOf(slots); + } + + public AnimationContext(int inventorySize, @NotNull List slots, @Nullable Player player, @Nullable Inventory inventory) { + this(inventorySize, slots, player, inventory, ThreadLocalRandom.current()); + } + + /** + * Rolls a chance. + * + *

Everything an animation leaves to chance goes through here, so a test giving the context a + * seeded {@link Random} gets the same animation every time.

+ * + * @param chance the chance in percent, 100 always passing and 0 never + * @return true if the roll passed + */ + public boolean roll(float chance) { + if (chance >= 100) return true; + if (chance <= 0) return false; + + return this.random.nextDouble() * 100 < chance; + } + + /** + * Picks a number of entries at random, without repeating any of them. + * + * @param values the entries to pick from + * @param count how many to pick; anything from the size of the list upwards returns them all + * @param the type of the entries + * @return the entries picked, in the order they were given + */ + @NotNull + public List pickSome(@NotNull List values, int count) { + if (count <= 0 || count >= values.size()) return List.copyOf(values); + + List shuffled = new ArrayList<>(values); + Collections.shuffle(shuffled, this.random); + + List picked = new ArrayList<>(shuffled.subList(0, count)); + picked.sort(Comparator.comparingInt(values::indexOf)); + return picked; + } + + /** + * Picks one of the variants of an animation, respecting their weights. + * + * @param variants the variants to pick from + * @return the variant picked, or null if there is nothing to pick from + */ + @Nullable + public AnimationVariant pickVariant(@NotNull List variants) { + if (variants.isEmpty()) return null; + if (variants.size() == 1) return variants.getFirst(); + + int total = 0; + for (AnimationVariant variant : variants) { + total += variant.weight(); + } + + int roll = this.random.nextInt(total); + for (AnimationVariant variant : variants) { + roll -= variant.weight(); + if (roll < 0) return variant; + } + return variants.getLast(); + } + + @Nullable + public MenuItemStack pick(@NotNull List choices) { + if (choices.isEmpty()) return null; + if (choices.size() == 1) return choices.getFirst().item(); + + int total = 0; + for (ItemChoice choice : choices) { + total += choice.weight(); + } + + int roll = this.random.nextInt(total); + for (ItemChoice choice : choices) { + roll -= choice.weight(); + if (roll < 0) return choice.item(); + } + return choices.getLast().item(); + } + + /** + * The same context, for another set of slots. + * + * @param slots the slots to order + * @return the new context + */ + @NotNull + public AnimationContext withSlots(@NotNull List slots) { + return new AnimationContext(this.inventorySize, slots, this.player, this.inventory, this.random); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationManager.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationManager.java new file mode 100644 index 000000000..8692b54d2 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationManager.java @@ -0,0 +1,97 @@ +package fr.maxlego08.menu.api.animation.slot; + +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Optional; + +/** + * Holds the animations of the {@code animations} folder, so that an inventory, an action or another + * plugin can use one by name. + * + *

Animations registered by a plugin live alongside the ones loaded from disk, but are kept when + * the folder is reloaded: a plugin registers its own once, at startup.

+ */ +public interface AnimationManager { + + /** + * Gets an animation by name, ignoring the case. + * + * @param name the name of the animation + * @return the animation, if it exists + */ + @NotNull + Optional getAnimation(@NotNull String name); + + /** + * Gets every animation currently known. + * + * @return the animations + */ + @NotNull + Collection getAnimations(); + + /** + * Registers an animation, replacing any animation of the same name. + * + *

An animation registered this way survives a reload of the folder, so a plugin only has to + * register it once.

+ * + * @param animation the animation to register + */ + void registerAnimation(@NotNull Animation animation); + + /** + * Registers an animation written in code. + * + * @param animation the animation to register + * @see CustomAnimation + */ + default void registerAnimation(@NotNull CustomAnimation animation) { + this.registerAnimation(animation.toAnimation()); + } + + /** + * Removes an animation. + * + * @param name the name of the animation + */ + void unregisterAnimation(@NotNull String name); + + /** + * Reloads every animation from the {@code animations} folder, keeping the ones registered by a + * plugin. + */ + void loadAnimations(); + + /** + * Plays an animation on the inventory a player is looking at. + * + * @param player the player + * @param name the name of the animation + * @param playback how to play it + * @return how long the animation lasts, in milliseconds, 0 if the player is not looking at a + * zMenu inventory, the animation does not exist, or it had nothing to play + */ + long play(@NotNull Player player, @NotNull String name, @NotNull AnimationPlayback playback); + + /** + * Plays an animation on the inventory a player is looking at. + * + * @param player the player + * @param animation the animation to play + * @param playback how to play it + * @return how long the animation lasts, in milliseconds, 0 if the player is not looking at a + * zMenu inventory or the animation had nothing to play + */ + long play(@NotNull Player player, @NotNull Animation animation, @NotNull AnimationPlayback playback); + + /** + * Finishes the animation playing on the inventory a player is looking at, applying everything + * that is left at once. + * + * @param player the player + */ + void stop(@NotNull Player player); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayMode.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayMode.java new file mode 100644 index 000000000..115c79d87 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayMode.java @@ -0,0 +1,94 @@ +package fr.maxlego08.menu.api.animation.slot; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; + +/** + * When the opening animation of an inventory is allowed to play. + * + *

Refreshing an inventory never plays the animation, whatever the mode: a page change or a + * {@code refresh inventory} action re-opens the same menu, and replaying the whole animation there + * would make a paginated menu unusable.

+ */ +public enum AnimationPlayMode { + + /** + * Plays every time the player opens the inventory. + */ + ALWAYS, + + /** + * Plays the first time the player opens this inventory, then never again until they reconnect. + */ + ONCE, + + /** + * Plays the first time the player opens this inventory and never again, remembered in the + * database so it survives a reconnect and a restart. + * + *

Falls back to {@link #ONCE} when the database is disabled, since there is then nowhere to + * remember it.

+ */ + ONCE_FOREVER; + + /** + * Whether this mode remembers itself beyond the session. + * + * @return true for {@link #ONCE_FOREVER} + */ + public boolean isPersistent() { + return this == ONCE_FOREVER; + } + + /** + * Whether this mode plays at most once. + * + * @return true for {@link #ONCE} and {@link #ONCE_FOREVER} + */ + public boolean isOnce() { + return this == ONCE || this == ONCE_FOREVER; + } + + /** + * Decides whether an animation may play, from when the player last saw it. + * + *

A cooldown and a once mode are independent: the cooldown alone turns {@link #ALWAYS} into + * "at most this often", and a once mode ignores it because it never gets a second chance + * anyway.

+ * + * @param lastPlayedAt when the player last saw it, in milliseconds since the epoch, or null if + * they never have + * @param now the current time, in milliseconds since the epoch + * @param cooldown the minimum time between two plays, in milliseconds; 0 for none + * @return true if the animation may play + */ + @Contract(pure = true) + public boolean canPlay(@Nullable Long lastPlayedAt, long now, long cooldown) { + if (lastPlayedAt == null) return true; + if (this.isOnce()) return false; + return cooldown <= 0 || now - lastPlayedAt >= cooldown; + } + + /** + * Parses a mode from a configuration value. + * + * @param value the value to parse, can be null + * @param defaultValue the value to return if the given value is null or invalid + * @return the parsed mode + */ + @Contract(pure = true) + @NotNull + public static AnimationPlayMode parse(@Nullable String value, @NotNull AnimationPlayMode defaultValue) { + if (value == null) return defaultValue; + String name = value.toUpperCase(Locale.ROOT).replace('-', '_').trim(); + if (name.equals("FIRST_OPEN")) return ONCE; + try { + return AnimationPlayMode.valueOf(name); + } catch (IllegalArgumentException exception) { + return defaultValue; + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayback.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayback.java new file mode 100644 index 000000000..ae61e5e45 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayback.java @@ -0,0 +1,62 @@ +package fr.maxlego08.menu.api.animation.slot; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; + +/** + * How an animation is played on an inventory. + */ +public enum AnimationPlayback { + + /** + * The buttons are hidden and revealed again. This is what opening an inventory does. + */ + REVEAL, + + /** + * Only the temporary items play. The buttons are never hidden and keep working, so the animation + * draws over a menu the player is already looking at. + * + *

Group boundaries still mean what they would have meant: {@code group:2} is the moment group + * 2 would have started, so an animation reads the same whichever way it is played.

+ */ + OVERLAY, + + /** + * The buttons are taken away one by one, in the order the animation gives. This is the reverse + * of {@link #REVEAL}, and is what a menu closing itself does. + * + *

Nothing is hidden up front: the menu is whole when it starts and empty when it ends.

+ */ + CONCEAL; + + /** + * Whether this playback hides the buttons before putting them back. + * + * @return true for {@link #REVEAL} only + */ + public boolean hidesButtons() { + return this == REVEAL; + } + + /** + * Parses a playback from a configuration value. + * + * @param value the value to parse, can be null + * @param defaultValue the value to return if the given value is null or invalid + * @return the parsed playback + */ + @Contract(pure = true) + @NotNull + public static AnimationPlayback parse(@Nullable String value, @NotNull AnimationPlayback defaultValue) { + if (value == null) return defaultValue; + try { + return AnimationPlayback.valueOf(value.toUpperCase(Locale.ROOT).replace('-', '_').trim()); + } catch (IllegalArgumentException exception) { + return defaultValue; + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationSlots.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationSlots.java new file mode 100644 index 000000000..e7090da94 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/AnimationSlots.java @@ -0,0 +1,82 @@ +package fr.maxlego08.menu.api.animation.slot; + +/** + * Maps the slots of the menu and the slots of the player inventory into the one space an animation + * is laid out over. + * + *

An animation should be able to sweep from the menu into the player inventory without a seam, so + * the two are compiled together: the slots of the menu come first, then the slots of the player + * inventory as they appear on screen.

+ * + *

That last part is the reason this class exists. Bukkit numbers the player inventory with the + * hotbar first, slots 0 to 8, and the three rows above it from 9 to 35, which is the opposite of the + * order they are displayed in. Animating over the raw numbers would make a spiral or a snake jump + * from the bottom row back to the top.

+ */ +public final class AnimationSlots { + + /** + * The number of player inventory slots taken part in an animation: the three rows of storage and + * the hotbar. + */ + public static final int PLAYER_SIZE = 36; + + /** + * The first player inventory slot as Bukkit numbers them, above the hotbar. + */ + private static final int FIRST_STORAGE_SLOT = 9; + + private AnimationSlots() { + } + + /** + * The total number of slots an animation is laid out over. + * + * @param inventorySize the size of the menu + * @return the size of the menu plus the player inventory + */ + public static int totalSize(int inventorySize) { + return inventorySize + PLAYER_SIZE; + } + + /** + * Turns a player inventory slot into the slot the animation uses for it. + * + * @param inventorySize the size of the menu + * @param playerSlot the slot as Bukkit numbers it, 0 to 35 + * @return the slot in the animation space + */ + public static int fromPlayer(int inventorySize, int playerSlot) { + int display = playerSlot >= FIRST_STORAGE_SLOT + ? playerSlot - FIRST_STORAGE_SLOT + : (PLAYER_SIZE - FIRST_STORAGE_SLOT) + playerSlot; + + return inventorySize + display; + } + + /** + * Turns a slot of the animation space back into a player inventory slot. + * + * @param inventorySize the size of the menu + * @param slot the slot in the animation space + * @return the slot as Bukkit numbers it, 0 to 35 + */ + public static int toPlayer(int inventorySize, int slot) { + int display = slot - inventorySize; + + return display < PLAYER_SIZE - FIRST_STORAGE_SLOT + ? display + FIRST_STORAGE_SLOT + : display - (PLAYER_SIZE - FIRST_STORAGE_SLOT); + } + + /** + * Returns whether a slot of the animation space belongs to the player inventory. + * + * @param inventorySize the size of the menu + * @param slot the slot in the animation space + * @return true if the slot is in the player inventory + */ + public static boolean isPlayer(int inventorySize, int slot) { + return slot >= inventorySize; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/CustomAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/CustomAnimation.java new file mode 100644 index 000000000..fd6996d39 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/CustomAnimation.java @@ -0,0 +1,350 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.order.SequentialSlotAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationSeed; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.item.AnimationKeyframe; +import fr.maxlego08.menu.api.animation.slot.settings.item.TemporaryItem; +import fr.maxlego08.menu.api.requirement.Action; +import fr.maxlego08.menu.api.requirement.Requirement; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * An animation written in code rather than in a file. + * + *

Extend it, override only what you care about, and register the result:

+ * + *
+ * public class GlowAnimation extends CustomAnimation {
+ *
+ *     public GlowAnimation() {
+ *         super("glow");
+ *     }
+ *
+ *     @Override
+ *     public SlotAnimation getAnimation() {
+ *         return new SpiralSlotAnimation();
+ *     }
+ *
+ *     @Override
+ *     public long getInterval() {
+ *         return 30;
+ *     }
+ *
+ *     @Override
+ *     public List<TemporaryItem> getTemporaryItems() {
+ *         return List.of(temporaryItem("flash", slots(10, 16), item, AnimationAnchor.START, AnimationAnchor.at(400)));
+ *     }
+ * }
+ *
+ * inventoryManager.getAnimationManager().registerAnimation(new GlowAnimation());
+ * 
+ * + *

Every hook has a default, so an animation may be as small as an ordering. Nothing here is + * required beyond the name.

+ */ +public abstract class CustomAnimation { + + private final String name; + + /** + * @param name the name the animation is registered under, and used by {@code file} in a menu + */ + protected CustomAnimation(@NotNull String name) { + this.name = name; + } + + /** + * Gets the name of this animation. + * + * @return the name + */ + @Contract(pure = true) + @NotNull + public final String getName() { + return this.name; + } + + /** + * The order the slots are revealed in. + * + * @return the ordering, sequential by default + */ + @NotNull + public SlotAnimation getAnimation() { + return new SequentialSlotAnimation(); + } + + /** + * Whether the order is played backwards. + * + * @return true to reverse it, false by default + */ + public boolean isReverse() { + return false; + } + + /** + * The delay between two steps, in milliseconds. + * + * @return the interval, 50 by default + */ + public long getInterval() { + return 50; + } + + /** + * The number of slots revealed on each step. + * + * @return the number of slots, 1 by default + */ + public int getSlotsPerStep() { + return 1; + } + + /** + * The delay before the first group starts, in milliseconds. + * + * @return the delay, none by default + */ + public long getDelay() { + return 0; + } + + /** + * The sound played when a slot is revealed. + * + * @return the sound, none by default + */ + @Nullable + public SoundOption getSound() { + return null; + } + + /** + * Whether the inventory ignores clicks while the animation plays. + * + * @return true to freeze it, which is the default + */ + public boolean isFreeze() { + return true; + } + + /** + * When the animation is allowed to play. + * + * @return the mode, every open by default + */ + @NotNull + public AnimationPlayMode getPlayMode() { + return AnimationPlayMode.ALWAYS; + } + + /** + * The groups that differ from the defaults above, keyed by group number. + * + *

Buttons pick their group with {@code animation.group}; a group missing from this map uses + * everything this class returns.

+ * + * @return the groups, none by default + */ + @NotNull + public Map getGroups() { + return Map.of(); + } + + /** + * Where the randomness of this animation comes from. + * + * @return the seed, a fresh roll on every play by default + */ + @NotNull + public AnimationSeed getSeed() { + return AnimationSeed.RANDOM; + } + + /** + * What runs when this animation starts. + * + * @return the actions, none by default + */ + @NotNull + public List getStartActions() { + return List.of(); + } + + /** + * What runs when this animation is over, however it ended. + * + * @return the actions, none by default + */ + @NotNull + public List getEndActions() { + return List.of(); + } + + /** + * What the player must satisfy for this animation to play. + * + * @return the requirement, none by default + */ + @Nullable + public Requirement getRequirement() { + return null; + } + + /** + * The items that exist only while the animation plays. + * + * @return the temporary items, none by default + */ + @NotNull + public List getTemporaryItems() { + return List.of(); + } + + /** + * Builds the settings this class describes. + * + * @return the settings + */ + @Contract(pure = true) + @NotNull + public final SlotAnimationSettings toSettings() { + SlotAnimationGroup defaults = new SlotAnimationGroup(this.getAnimation(), this.getInterval(), this.getSlotsPerStep(), this.getDelay(), this.getSound(), this.isReverse()); + + return new SlotAnimationSettings(defaults, this.getGroups(), this.isFreeze(), this.getPlayMode(), + this.getTemporaryItems(), this.getRequirement(), 100, List.of(), this.getSeed(), + this.getStartActions(), this.getEndActions()); + } + + /** + * Builds the animation this class describes, ready to be registered or played. + * + * @return the animation + */ + @Contract(pure = true) + @NotNull + public final Animation toAnimation() { + return new Animation(this.name, this.toSettings()); + } + + /** + * A group overriding some of the defaults of this class. + * + * @param animation the ordering, null to keep the one of this class + * @param interval the delay between two steps, in milliseconds + * @param slotsPerStep the number of slots revealed on each step + * @param delay the delay before the group starts + * @return the group + */ + @Contract(pure = true) + @NotNull + protected final SlotAnimationGroup group(@Nullable SlotAnimation animation, long interval, int slotsPerStep, long delay) { + return new SlotAnimationGroup(animation == null ? this.getAnimation() : animation, interval, slotsPerStep, delay, this.getSound(), this.isReverse()); + } + + /** + * A map of groups, in the order they are given. + * + * @param first the number of the first group + * @param groups the groups, starting at {@code first} + * @return the groups, keyed by number + */ + @Contract(pure = true) + @NotNull + protected final Map groups(int first, @NotNull SlotAnimationGroup... groups) { + Map map = new LinkedHashMap<>(); + for (int index = 0; index < groups.length; index++) { + map.put(first + index, groups[index]); + } + return map; + } + + /** + * A temporary item appearing once and going away once. + * + * @param name the name of the item, used to report errors + * @param slots the slots it occupies + * @param item the item to display + * @param show when it appears + * @param hide when it is removed + * @return the temporary item + */ + @Contract(pure = true) + @NotNull + protected final TemporaryItem temporaryItem(@NotNull String name, @NotNull List slots, @NotNull MenuItemStack item, @NotNull AnimationAnchor show, @NotNull AnimationAnchor hide) { + return new TemporaryItem(name, slots, List.of( + new AnimationKeyframe(show, item, null), + new AnimationKeyframe(hide, null, null))); + } + + /** + * A temporary item described by its keyframes. + * + * @param name the name of the item, used to report errors + * @param slots the slots it occupies + * @param frames what it does over time + * @return the temporary item + */ + @Contract(pure = true) + @NotNull + protected final TemporaryItem temporaryItem(@NotNull String name, @NotNull List slots, @NotNull AnimationKeyframe... frames) { + return new TemporaryItem(name, slots, List.of(frames)); + } + + /** + * A keyframe displaying an item from that moment on. + * + * @param at when it happens + * @param item the item to display + * @param sound the sound to play, or null + * @return the keyframe + */ + @Contract(pure = true) + @NotNull + protected final AnimationKeyframe frame(@NotNull AnimationAnchor at, @NotNull MenuItemStack item, @Nullable SoundOption sound) { + return new AnimationKeyframe(at, item, sound); + } + + /** + * A keyframe removing the temporary item. + * + * @param at when it happens + * @param sound the sound to play, or null + * @return the keyframe + */ + @Contract(pure = true) + @NotNull + protected final AnimationKeyframe removal(@NotNull AnimationAnchor at, @Nullable SoundOption sound) { + return new AnimationKeyframe(at, null, sound); + } + + /** + * The slots from one to another, both included. + * + * @param from the first slot + * @param to the last slot + * @return the slots + */ + @Contract(pure = true) + @NotNull + protected final List slots(int from, int to) { + List slots = new ArrayList<>(Math.max(0, to - from + 1)); + for (int slot = Math.min(from, to); slot <= Math.max(from, to); slot++) { + slots.add(slot); + } + return slots; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/SlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/SlotAnimation.java new file mode 100644 index 000000000..8ac3d0321 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/SlotAnimation.java @@ -0,0 +1,43 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Decides in which order the slots of an inventory are revealed when it opens. + * + *

An implementation only reorders the slots it is given; the timing and the number of slots + * revealed per step come from {@link SlotAnimationSettings}. Implementations must return every slot + * they receive, exactly once, so that no item is ever lost.

+ */ +public interface SlotAnimation { + + /** + * Orders the slots to reveal. + * + * @param inventorySize the number of slots being laid out: a menu and the player inventory + * together, so 90 for a double chest. Always a multiple of 9 when the menu + * is a chest, since both parts are + * @param slots the slots holding an item, in ascending order + * @return the same slots, in the order they must be revealed + */ + @NotNull + List order(int inventorySize, @NotNull List slots); + + /** + * Orders the slots, knowing who the animation is played for. + * + *

This is what the compiler calls. Override it instead of {@link #order(int, List)} when the + * order depends on the player or on the menu; the default simply forwards, so an implementation + * that does not care only has to write the simple one.

+ * + * @param context what to order, and who for + * @return the same slots, in the order they must be revealed + */ + @NotNull + default List order(@NotNull AnimationContext context) { + return this.order(context.inventorySize(), context.slots()); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CheckerboardSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CheckerboardSlotAnimation.java new file mode 100644 index 000000000..7f342c0a6 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CheckerboardSlotAnimation.java @@ -0,0 +1,26 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals one slot out of two first, then fills the gaps, so the inventory appears as a chequered + * pattern before it completes. + */ +public class CheckerboardSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> ((slot / width) + (slot % width)) % 2) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/ColumnsSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/ColumnsSlotAnimation.java new file mode 100644 index 000000000..8c9ef8498 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/ColumnsSlotAnimation.java @@ -0,0 +1,27 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots column by column, from left to right, each column top to bottom. + * + *

Unlike {@link VerticalSnakeSlotAnimation} every column is walked in the same direction.

+ */ +public class ColumnsSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> slot % width) + .thenComparingInt(slot -> slot / width)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimation.java new file mode 100644 index 000000000..9e709dc90 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimation.java @@ -0,0 +1,51 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Reveals the slots in the order they are listed. + * + *

The list uses the same syntax as the slots of a button, so a line may be a single slot or a + * range:

+ * + *
+ * type: CUSTOM
+ * order:
+ *   - 13
+ *   - 12
+ *   - 14
+ *   - 4-22
+ * 
+ * + *

A slot listed twice keeps its first position, and slots the list does not mention are revealed + * last, in ascending order, so a partial list still describes a complete animation.

+ */ +public class CustomSlotAnimation implements SlotAnimation { + + private final Map positionBySlot; + + /** + * @param order the slots in the order they must be revealed + */ + public CustomSlotAnimation(@NotNull List order) { + Set unique = new LinkedHashSet<>(order); + + this.positionBySlot = new HashMap<>(unique.size()); + int position = 0; + for (int slot : unique) { + this.positionBySlot.put(slot, position++); + } + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> this.positionBySlot.getOrDefault(slot, Integer.MAX_VALUE)) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/FromCenterSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/FromCenterSlotAnimation.java new file mode 100644 index 000000000..27455fd15 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/FromCenterSlotAnimation.java @@ -0,0 +1,33 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots closest to the middle of the inventory first, expanding outwards. + */ +public class FromCenterSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + int height = SlotGrid.height(inventorySize); + + double centerColumn = (width - 1) / 2.0; + double centerRow = (height - 1) / 2.0; + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingDouble((Integer slot) -> { + double columnDistance = (slot % width) - centerColumn; + double rowDistance = (slot / width) - centerRow; + return (columnDistance * columnDistance) + (rowDistance * rowDistance); + }) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/HorizontalSnakeSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/HorizontalSnakeSlotAnimation.java new file mode 100644 index 000000000..a5ad4a50f --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/HorizontalSnakeSlotAnimation.java @@ -0,0 +1,29 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots row by row, alternating the direction of each row, so the reveal snakes from the + * top left corner down to the bottom of the inventory. + */ +public class HorizontalSnakeSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> slot / width) + .thenComparingInt(slot -> { + int column = slot % width; + return (slot / width) % 2 == 0 ? column : width - 1 - column; + })); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimation.java new file mode 100644 index 000000000..98e772d1f --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimation.java @@ -0,0 +1,61 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Reveals the slots in the order drawn in a matrix. + * + *

Each row of the matrix describes a row of the inventory, and each character a slot. Every + * distinct character is a step, and the steps are played in the order the characters sort in, so + * {@code 1} comes before {@code 2} and digits come before letters. A space means the slot is not + * part of the drawing.

+ * + *
+ * matrix:
+ *   - "111111111"
+ *   - "122222221"
+ *   - "111111111"
+ * 
+ * + *

Slots the matrix does not cover are revealed last, in ascending order, so a matrix shorter than + * the inventory still describes a complete animation.

+ */ +public class MatrixSlotAnimation implements SlotAnimation { + + private final Map stepBySlot; + + /** + * @param rows the rows of the matrix, at most 9 characters each + */ + public MatrixSlotAnimation(@NotNull List rows) { + Map charBySlot = new HashMap<>(); + TreeSet characters = new TreeSet<>(); + + for (int row = 0; row < rows.size(); row++) { + String line = rows.get(row); + for (int column = 0; column < Math.min(line.length(), SlotGrid.CHEST_WIDTH); column++) { + char character = line.charAt(column); + if (character == ' ') continue; + + charBySlot.put((row * SlotGrid.CHEST_WIDTH) + column, character); + characters.add(character); + } + } + + List ordered = new ArrayList<>(characters); + this.stepBySlot = new HashMap<>(charBySlot.size()); + charBySlot.forEach((slot, character) -> this.stepBySlot.put(slot, ordered.indexOf(character))); + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> this.stepBySlot.getOrDefault(slot, Integer.MAX_VALUE)) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/RandomSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/RandomSlotAnimation.java new file mode 100644 index 000000000..d440088af --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/RandomSlotAnimation.java @@ -0,0 +1,36 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Reveals the slots in a random order. + */ +public class RandomSlotAnimation implements SlotAnimation { + + private final Random random; + + public RandomSlotAnimation() { + this(null); + } + + /** + * @param random the source of randomness, or null to use {@link ThreadLocalRandom} + */ + public RandomSlotAnimation(Random random) { + this.random = random; + } + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + List ordered = new ArrayList<>(slots); + Collections.shuffle(ordered, this.random == null ? ThreadLocalRandom.current() : this.random); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SequentialSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SequentialSlotAnimation.java new file mode 100644 index 000000000..dfaddc713 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SequentialSlotAnimation.java @@ -0,0 +1,17 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Reveals the slots from the first to the last, left to right and row by row. + */ +public class SequentialSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + return List.copyOf(slots); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SlotGrid.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SlotGrid.java new file mode 100644 index 000000000..0c10d9be1 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SlotGrid.java @@ -0,0 +1,39 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; + +/** + * Helpers shared by the grid based {@link SlotAnimation} implementations. + * + *

Inventories whose size is a multiple of 9 are treated as a 9 wide grid, which covers every + * chest. Any other size is treated as a single row, so the grid based animations degrade to a plain + * left to right reveal instead of computing meaningless coordinates.

+ */ +public final class SlotGrid { + + public static final int CHEST_WIDTH = 9; + + private SlotGrid() { + } + + /** + * Gets the width of the grid for an inventory of the given size. + * + * @param inventorySize the inventory size + * @return the number of columns + */ + public static int width(int inventorySize) { + return inventorySize > 0 && inventorySize % CHEST_WIDTH == 0 ? CHEST_WIDTH : Math.max(1, inventorySize); + } + + /** + * Gets the number of rows of the grid for an inventory of the given size. + * + * @param inventorySize the inventory size + * @return the number of rows + */ + public static int height(int inventorySize) { + int width = width(inventorySize); + return Math.max(1, (int) Math.ceil((double) inventorySize / width)); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SpiralSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SpiralSlotAnimation.java new file mode 100644 index 000000000..7db78024d --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/SpiralSlotAnimation.java @@ -0,0 +1,50 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Reveals the slots by walking the inventory in an inward spiral, starting from the top left corner + * and turning clockwise. + */ +public class SpiralSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + int height = SlotGrid.height(inventorySize); + + Set remaining = new TreeSet<>(slots); + Set ordered = new LinkedHashSet<>(slots.size()); + + int top = 0, bottom = height - 1, left = 0, right = width - 1; + while (top <= bottom && left <= right) { + for (int column = left; column <= right; column++) this.take(ordered, remaining, top * width + column); + top++; + + for (int row = top; row <= bottom; row++) this.take(ordered, remaining, row * width + right); + right--; + + if (top <= bottom) { + for (int column = right; column >= left; column--) this.take(ordered, remaining, bottom * width + column); + bottom--; + } + + if (left <= right) { + for (int row = bottom; row >= top; row--) this.take(ordered, remaining, row * width + left); + left++; + } + } + + ordered.addAll(remaining); + return new ArrayList<>(ordered); + } + + private void take(Set ordered, Set remaining, int slot) { + if (remaining.remove(slot)) { + ordered.add(slot); + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/VerticalSnakeSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/VerticalSnakeSlotAnimation.java new file mode 100644 index 000000000..087b7eff8 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/VerticalSnakeSlotAnimation.java @@ -0,0 +1,30 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots column by column, alternating the direction of each column, so the reveal snakes + * from the top left corner across to the right of the inventory. + */ +public class VerticalSnakeSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + int height = SlotGrid.height(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> slot % width) + .thenComparingInt(slot -> { + int row = slot / width; + return (slot % width) % 2 == 0 ? row : height - 1 - row; + })); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/WaveSlotAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/WaveSlotAnimation.java new file mode 100644 index 000000000..4c90606ac --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/order/WaveSlotAnimation.java @@ -0,0 +1,25 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Reveals the slots as a diagonal sweep running from the top left corner to the bottom right one. + */ +public class WaveSlotAnimation implements SlotAnimation { + + @Override + public @NotNull List order(int inventorySize, @NotNull List slots) { + int width = SlotGrid.width(inventorySize); + + List ordered = new ArrayList<>(slots); + ordered.sort(Comparator + .comparingInt((Integer slot) -> (slot / width) + (slot % width)) + .thenComparingInt(slot -> slot)); + return ordered; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchor.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchor.java new file mode 100644 index 000000000..5a4b4202f --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchor.java @@ -0,0 +1,169 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Map; + +/** + * A point in time inside an animation, written in whichever unit suits the author. + * + * + * + * + * + * + * + * + *
Accepted forms
{@code 250}250 milliseconds after the animation started
{@code group:2} or {@code group:2.start}the moment group 2 starts
{@code group:2.end}the moment group 2 is over
{@code start}the beginning of the animation
{@code end}the end of the last group
+ * + *

An anchor is parsed when the inventory is loaded, and resolved to a time when the animation is + * compiled, because the boundaries of the groups are only known then.

+ * + * @param type the kind of anchor + * @param millis the time, only used by {@link Type#MILLIS} + * @param group the group, only used by {@link Type#GROUP_START} and {@link Type#GROUP_END} + */ +public record AnimationAnchor(@NotNull Type type, long millis, int group) { + + public enum Type {START, END, MILLIS, GROUP_START, GROUP_END} + + /** + * The beginning of the animation. + */ + public static final AnimationAnchor START = new AnimationAnchor(Type.START, 0, 0); + + /** + * The end of the last group. + */ + public static final AnimationAnchor END = new AnimationAnchor(Type.END, 0, 0); + + /** + * An anchor at a fixed time. + * + * @param millis the time in milliseconds + * @return the anchor + */ + @Contract(pure = true) + @NotNull + public static AnimationAnchor at(long millis) { + return new AnimationAnchor(Type.MILLIS, Math.max(0, millis), 0); + } + + /** + * An anchor on the start of a group. + * + * @param group the group number + * @return the anchor + */ + @Contract(pure = true) + @NotNull + public static AnimationAnchor groupStart(int group) { + return new AnimationAnchor(Type.GROUP_START, 0, group); + } + + /** + * An anchor on the end of a group. + * + * @param group the group number + * @return the anchor + */ + @Contract(pure = true) + @NotNull + public static AnimationAnchor groupEnd(int group) { + return new AnimationAnchor(Type.GROUP_END, 0, group); + } + + /** + * Parses an anchor written in a configuration file, in either of its two forms. + * + *

The compact form is a single value, parsed by {@link #parse(String)}. The explicit form is a + * section, given here as its values:

+ * + *
+     * show:
+     *   group: 2
+     *   at: end      # start by default
+     *
+     * show:
+     *   at: 250      # anything the compact form accepts
+     * 
+ * + * @param value the value to parse: a section as a map, or anything else for the compact form + * @return the anchor, or null if the value is not a valid anchor + */ + @Contract(pure = true) + @Nullable + public static AnimationAnchor parseValue(@Nullable Object value) { + if (value == null) return null; + + if (value instanceof Map map) { + Object group = map.get("group"); + Object at = map.get("at"); + + if (group != null) { + int groupNumber; + try { + groupNumber = group instanceof Number number ? number.intValue() : Integer.parseInt(group.toString().trim()); + } catch (NumberFormatException exception) { + return null; + } + + if (at == null) return groupStart(groupNumber); + String boundary = at.toString().trim().toLowerCase(Locale.ROOT); + if (boundary.equals("start")) return groupStart(groupNumber); + if (boundary.equals("end")) return groupEnd(groupNumber); + return null; + } + + return at == null ? null : parse(at.toString()); + } + + return parse(value.toString()); + } + + /** + * Parses an anchor written in its compact form. + * + * @param value the value to parse + * @return the anchor, or null if the value is not a valid anchor + */ + @Contract(pure = true) + @Nullable + public static AnimationAnchor parse(@Nullable String value) { + if (value == null) return null; + + String trimmed = value.trim().toLowerCase(Locale.ROOT); + if (trimmed.isEmpty()) return null; + + if (trimmed.equals("start")) return START; + if (trimmed.equals("end")) return END; + + if (trimmed.startsWith("group:")) { + String rest = trimmed.substring("group:".length()); + + boolean end = false; + if (rest.endsWith(".end")) { + end = true; + rest = rest.substring(0, rest.length() - ".end".length()); + } else if (rest.endsWith(".start")) { + rest = rest.substring(0, rest.length() - ".start".length()); + } + + try { + int group = Integer.parseInt(rest.trim()); + return end ? groupEnd(group) : groupStart(group); + } catch (NumberFormatException exception) { + return null; + } + } + + try { + return at(Long.parseLong(trimmed)); + } catch (NumberFormatException exception) { + return null; + } + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeed.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeed.java new file mode 100644 index 000000000..59c915a8a --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeed.java @@ -0,0 +1,92 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Where the randomness of an animation comes from. + * + *

By default every play rolls afresh. Seeding it from the player instead turns variety into + * personality: a given player always gets the same variant, the same scatter and the same choices, + * so the menu looks like theirs rather than looking different every time they open it.

+ * + * @param type the kind of seed + * @param value the seed itself, only used by {@link Type#FIXED} + */ +public record AnimationSeed(@NotNull Type type, long value) { + + public enum Type { + + /** + * A fresh roll on every play. + */ + RANDOM, + + /** + * The same roll every time, for a given player and a given animation. + */ + PLAYER, + + /** + * The same roll for everyone, every time. + */ + FIXED + } + + /** + * A fresh roll on every play, which is what an animation does unless it says otherwise. + */ + public static final AnimationSeed RANDOM = new AnimationSeed(Type.RANDOM, 0); + + /** + * Parses a seed from a configuration value. + * + *

{@code random} rolls afresh, {@code player} is stable per player, and a number is stable + * for everyone.

+ * + * @param value the value to parse, can be null + * @return the seed, {@link #RANDOM} if the value is null or not understood + */ + @Contract(pure = true) + @NotNull + public static AnimationSeed parse(@Nullable String value) { + if (value == null) return RANDOM; + + String trimmed = value.trim().toLowerCase(Locale.ROOT); + if (trimmed.isEmpty() || trimmed.equals("random")) return RANDOM; + if (trimmed.equals("player")) return new AnimationSeed(Type.PLAYER, 0); + + try { + return new AnimationSeed(Type.FIXED, Long.parseLong(trimmed)); + } catch (NumberFormatException exception) { + return RANDOM; + } + } + + /** + * Builds the source the animation takes its rolls from. + * + *

The name of the animation is mixed in, so two animations seeded from the same player do not + * end up producing the same pattern as one another.

+ * + * @param playerId the player the animation is played for, null outside of one + * @param animationName the name of the animation + * @return the source + */ + @NotNull + public Random create(@Nullable UUID playerId, @NotNull String animationName) { + return switch (this.type) { + case RANDOM -> ThreadLocalRandom.current(); + case FIXED -> new Random(this.value + animationName.hashCode()); + case PLAYER -> playerId == null + ? ThreadLocalRandom.current() + : new Random((playerId.getMostSignificantBits() ^ playerId.getLeastSignificantBits()) * 31 + animationName.hashCode()); + }; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationVariant.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationVariant.java new file mode 100644 index 000000000..94a09b551 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationVariant.java @@ -0,0 +1,21 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.jetbrains.annotations.NotNull; + +/** + * One of the animations a menu may play, and how likely it is compared to the others. + * + *

A variant is picked when the animation is about to play, so a menu can spiral one time and + * sweep the next.

+ * + * @param name the name of the variant in the configuration, used to report errors + * @param settings what that variant does + * @param weight how likely it is; a variant of weight 3 is picked three times as often as one of + * weight 1 + */ +public record AnimationVariant(@NotNull String name, @NotNull SlotAnimationSettings settings, int weight) { + + public AnimationVariant { + weight = Math.max(1, weight); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/ButtonAnimationSettings.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/ButtonAnimationSettings.java new file mode 100644 index 000000000..5325fb358 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/ButtonAnimationSettings.java @@ -0,0 +1,49 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.Nullable; + +/** + * How a single button takes part in the opening animation of its inventory. + * + * @param enabled whether the slots of the button are animated at all; a disabled button is displayed + * immediately, even while the rest of the inventory is still being revealed + * @param group the group the button belongs to, groups are played in ascending order + * @param delay an extra delay for the slots of this button, in milliseconds, counted from the + * start of its group + * @param sound the sound played when a slot of this button is revealed, overriding the one of its + * group, or null to use the group one + * @param allowTemporaryItems whether a temporary item may be drawn over the slots of this button; a + * button holding items a player can take never allows it, so that nothing + * decorative can be picked up + */ +public record ButtonAnimationSettings(boolean enabled, int group, long delay, @Nullable SoundOption sound, boolean allowTemporaryItems) { + + public ButtonAnimationSettings(boolean enabled, int group, long delay, @Nullable SoundOption sound) { + this(enabled, group, delay, sound, true); + } + + public ButtonAnimationSettings(boolean enabled, int group, long delay) { + this(enabled, group, delay, null, true); + } + + /** + * Used by every button that does not configure its animation. + */ + public static final ButtonAnimationSettings DEFAULT = new ButtonAnimationSettings(true, 1, 0, null, true); + + /** + * Used by a button configured with {@code animation: false}. + */ + public static final ButtonAnimationSettings DISABLED = new ButtonAnimationSettings(false, 1, 0, null, true); + + /** + * Used by a button that takes part in the animation but refuses to be covered by a temporary + * item, and that configured nothing else. + */ + public static final ButtonAnimationSettings NOT_COVERABLE = new ButtonAnimationSettings(true, 1, 0, null, false); + + public ButtonAnimationSettings { + delay = Math.max(0, delay); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationGroup.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationGroup.java new file mode 100644 index 000000000..51f13912b --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationGroup.java @@ -0,0 +1,47 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * How one group of slots is revealed. + * + *

Ordering is a property of the group, not of a single button: a group is revealed as a whole, so + * an animation such as a spiral can span several buttons. A button only chooses the group it belongs + * to, and may add a delay of its own.

+ * + * @param animation the order in which the slots of the group are revealed + * @param interval the delay between two steps, in milliseconds + * @param slotsPerStep the number of slots revealed on each step + * @param delay the delay before the group starts, in milliseconds, counted from the end of + * the previous group + * @param sound the sound played when a slot of the group is revealed, or null for a silent + * group + * @param reverse whether the order produced by the animation is played backwards + * @param jitter how far a step may land from its scheduled time, in milliseconds, so a reveal + * reads as organic rather than mechanical; 0 keeps it exact + */ +public record SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay, @Nullable SoundOption sound, boolean reverse, long jitter) { + + public SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay, + @Nullable SoundOption sound, boolean reverse) { + this(animation, interval, slotsPerStep, delay, sound, reverse, 0); + } + + public SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay, @Nullable SoundOption sound) { + this(animation, interval, slotsPerStep, delay, sound, false, 0); + } + + public SlotAnimationGroup(@NotNull SlotAnimation animation, long interval, int slotsPerStep, long delay) { + this(animation, interval, slotsPerStep, delay, null, false, 0); + } + + public SlotAnimationGroup { + interval = Math.max(1, interval); + slotsPerStep = Math.max(1, slotsPerStep); + delay = Math.max(0, delay); + jitter = Math.max(0, jitter); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationSettings.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationSettings.java new file mode 100644 index 000000000..2190af8e5 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/SlotAnimationSettings.java @@ -0,0 +1,117 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.animation.slot.AnimationPlayMode; +import fr.maxlego08.menu.api.animation.slot.settings.item.TemporaryItem; +import fr.maxlego08.menu.api.requirement.Action; +import fr.maxlego08.menu.api.requirement.Requirement; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +/** + * Settings of the animation played when an inventory opens. + * + *

Every animated slot belongs to a group, group 1 by default. Groups are played one after the + * other, in ascending order, and each of them may override how it is revealed. A group without an + * explicit configuration uses {@code defaults}.

+ * + * @param defaults the configuration used by the groups that do not declare one + * @param groups the per group overrides, keyed by group number + * @param freeze whether the inventory ignores clicks while the animation plays + * @param play when the animation is allowed to play + * @param temporaryItems the items that exist only while the animation plays + * @param requirement what the player must satisfy for the animation to play, null for no + * condition + * @param chance the chance of the animation playing at all, in percent + * @param variants the alternatives to pick one from when the animation plays; when it is not + * empty the picked one describes what happens, and only the chance and the + * requirement of this one still apply + * @param seed where the randomness of the animation comes from + * @param startActions what runs when the animation starts, after it is known that it will play + * @param endActions what runs when it is over, however it ended + * @param cooldown the minimum time between two plays for one player, in milliseconds; 0 for + * none. Remembered in the database, so it survives a reconnect + */ +public record SlotAnimationSettings( + @NotNull SlotAnimationGroup defaults, + @NotNull Map groups, + boolean freeze, + @NotNull AnimationPlayMode play, + @NotNull List temporaryItems, + @Nullable Requirement requirement, + float chance, + @NotNull List variants, + @NotNull AnimationSeed seed, + @NotNull List startActions, + @NotNull List endActions, + long cooldown +) { + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance, @NotNull List variants, @NotNull AnimationSeed seed, @NotNull List startActions, @NotNull List endActions) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, variants, seed, startActions, endActions, 0); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance, @NotNull List variants, @NotNull AnimationSeed seed) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, variants, seed, List.of(), List.of(), 0); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance, @NotNull List variants) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, variants, AnimationSeed.RANDOM, List.of(), List.of()); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement, float chance) { + this(defaults, groups, freeze, play, temporaryItems, requirement, chance, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems, @Nullable Requirement requirement) { + this(defaults, groups, freeze, play, temporaryItems, requirement, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, + boolean freeze, @NotNull AnimationPlayMode play, @NotNull List temporaryItems) { + this(defaults, groups, freeze, play, temporaryItems, null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze, @NotNull AnimationPlayMode play) { + this(defaults, groups, freeze, play, List.of(), null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, @NotNull Map groups, boolean freeze) { + this(defaults, groups, freeze, AnimationPlayMode.ALWAYS, List.of(), null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings(@NotNull SlotAnimationGroup defaults, boolean freeze) { + this(defaults, Map.of(), freeze, AnimationPlayMode.ALWAYS, List.of(), null, 100, List.of(), AnimationSeed.RANDOM); + } + + public SlotAnimationSettings { + groups = Map.copyOf(groups); + temporaryItems = List.copyOf(temporaryItems); + variants = List.copyOf(variants); + startActions = List.copyOf(startActions); + endActions = List.copyOf(endActions); + cooldown = Math.max(0, cooldown); + } + + /** + * Gets the configuration of a group. + * + * @param group the group number + * @return the configuration of that group, or {@link #defaults()} if it declares none + */ + @NotNull + public SlotAnimationGroup group(int group) { + return this.groups.getOrDefault(group, this.defaults); + } + + /** + * Whether the player having seen this animation has to be remembered beyond the session. + * + * @return true when the mode or the cooldown needs the database + */ + public boolean needsPersistence() { + return this.play.isPersistent() || this.cooldown > 0; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimation.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimation.java new file mode 100644 index 000000000..8791bf84b --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimation.java @@ -0,0 +1,127 @@ +package fr.maxlego08.menu.api.animation.slot.settings.idle; + +import fr.maxlego08.menu.api.MenuItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * An animation a button plays on its own slots for as long as the menu stays open. + * + *

This is not the opening animation: there is no timeline, no group and no freeze. The frames + * are simply cycled on the slots of the button, which keeps working normally throughout.

+ * + *

The cycle only starts once the opening animation has revealed the slot, so the two never fight + * over it.

+ * + * @param frames the frames to cycle through, in order + * @param interval the delay between two frames, in milliseconds, used by every frame that does not + * declare a duration of its own + * @param loop whether the cycle restarts once it reaches the end; when false the button goes + * back to its real item and the cycle stops + * @param random whether the next frame is picked at random rather than taken in order + */ +public record ButtonIdleAnimation(@NotNull List frames, long interval, boolean loop, boolean random) { + + public ButtonIdleAnimation(@NotNull List frames, long interval, boolean loop) { + this(frames, interval, loop, false); + } + + public ButtonIdleAnimation { + frames = List.copyOf(frames); + interval = Math.max(1, interval); + } + + /** + * An animation cycling plain items, each lasting the interval and playing no sound. + * + * @param items the items to cycle through, in order + * @param interval the delay between two items, in milliseconds + * @param loop whether the cycle restarts once it reaches the end + * @param random whether the next item is picked at random rather than taken in order + * @return the animation + */ + @NotNull + public static ButtonIdleAnimation of(@NotNull List items, long interval, boolean loop, boolean random) { + return new ButtonIdleAnimation(items.stream().map(IdleFrame::new).toList(), interval, loop, random); + } + + /** + * An animation cycling plain items in order. + * + * @param items the items to cycle through, in order + * @param interval the delay between two items, in milliseconds + * @param loop whether the cycle restarts once it reaches the end + * @return the animation + */ + @NotNull + public static ButtonIdleAnimation of(@NotNull List items, long interval, boolean loop) { + return of(items, interval, loop, false); + } + + /** + * The items of the frames, in order. + * + * @return the items + */ + @NotNull + public List items() { + return this.frames.stream().map(IdleFrame::item).toList(); + } + + /** + * Gets the frame to display on a given turn. + * + * @param index the turn, starting at 0 + * @return the frame + */ + @NotNull + public IdleFrame frame(int index) { + return this.frames.get(Math.floorMod(index, this.frames.size())); + } + + /** + * Gets the frame to display on a given turn, picking at random when the cycle asks for it. + * + * @param index the turn, starting at 0 + * @param random the source to pick from + * @return the frame + */ + @NotNull + public IdleFrame frame(int index, @NotNull java.util.Random random) { + return this.random ? this.frames.get(random.nextInt(this.frames.size())) : this.frame(index); + } + + /** + * Gets the item to display on a given turn. + * + * @param index the turn, starting at 0 + * @return the item + */ + @NotNull + public MenuItemStack item(int index) { + return this.frame(index).item(); + } + + /** + * Gets the item to display on a given turn, picking at random when the cycle asks for it. + * + * @param index the turn, starting at 0 + * @param random the source to pick from + * @return the item + */ + @NotNull + public MenuItemStack item(int index, @NotNull java.util.Random random) { + return this.frame(index, random).item(); + } + + /** + * Returns whether the cycle is over. + * + * @param index the turn, starting at 0 + * @return true if there is nothing left to display + */ + public boolean isOver(int index) { + return !this.loop && index >= this.frames.size(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/IdleFrame.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/IdleFrame.java new file mode 100644 index 000000000..09e17aa1f --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/idle/IdleFrame.java @@ -0,0 +1,44 @@ +package fr.maxlego08.menu.api.animation.slot.settings.idle; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * One step of a {@link ButtonIdleAnimation}. + * + *

A frame holds how long it stays on screen and what it sounds like, so a cycle can hold a + * picture for a while and then flash through two others, rather than giving every picture the same + * share of the time.

+ * + * @param item the item to display + * @param duration how long the frame stays, in milliseconds; 0 uses the interval of the animation + * @param sound the sound played when the frame is displayed, or null for a silent frame + */ +public record IdleFrame(@NotNull MenuItemStack item, long duration, @Nullable SoundOption sound) { + + /** + * A frame lasting the interval of its animation, with no sound of its own. + * + * @param item the item to display + */ + public IdleFrame(@NotNull MenuItemStack item) { + this(item, 0, null); + } + + public IdleFrame { + duration = Math.max(0, duration); + } + + /** + * How long the frame lasts, falling back to the interval of the animation when it declares no + * duration of its own. + * + * @param interval the interval of the animation, in milliseconds + * @return the duration to use, in milliseconds + */ + public long duration(long interval) { + return this.duration > 0 ? this.duration : interval; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/AnimationKeyframe.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/AnimationKeyframe.java new file mode 100644 index 000000000..accf2f1c4 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/AnimationKeyframe.java @@ -0,0 +1,45 @@ +package fr.maxlego08.menu.api.animation.slot.settings.item; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * One moment in the life of a {@link TemporaryItem}: what it looks like from then on, or the moment + * it goes away. + * + * @param at when the keyframe happens + * @param item the item to display from then on, or null to remove the temporary item + * @param sound the sound to play, or null for a silent keyframe + * @param chance the chance of the keyframe happening, in percent. A keyframe that removes the item + * ignores it: an item that might not be removed would be left behind + * @param choices the items to pick from, one of which is displayed; when it is not empty it is used + * instead of {@code item} + */ +public record AnimationKeyframe(@NotNull AnimationAnchor at, @Nullable MenuItemStack item, @Nullable SoundOption sound, float chance, @NotNull List choices) { + + public AnimationKeyframe(@NotNull AnimationAnchor at, @Nullable MenuItemStack item, @Nullable SoundOption sound, float chance) { + this(at, item, sound, chance, List.of()); + } + + public AnimationKeyframe(@NotNull AnimationAnchor at, @Nullable MenuItemStack item, @Nullable SoundOption sound) { + this(at, item, sound, 100, List.of()); + } + + public AnimationKeyframe { + choices = List.copyOf(choices); + } + + /** + * Returns whether this keyframe removes the temporary item instead of changing it. + * + * @return true if the item goes away + */ + public boolean isRemoval() { + return this.item == null && this.choices.isEmpty(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/ItemChoice.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/ItemChoice.java new file mode 100644 index 000000000..7238de8c5 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/ItemChoice.java @@ -0,0 +1,22 @@ +package fr.maxlego08.menu.api.animation.slot.settings.item; + +import fr.maxlego08.menu.api.MenuItemStack; +import org.jetbrains.annotations.NotNull; + +/** + * One of the items a keyframe may display, and how likely it is compared to the others. + * + * @param item the item + * @param weight how likely it is; an item of weight 3 is picked three times as often as one of + * weight 1 + */ +public record ItemChoice(@NotNull MenuItemStack item, int weight) { + + public ItemChoice(@NotNull MenuItemStack item) { + this(item, 1); + } + + public ItemChoice { + weight = Math.max(1, weight); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/TemporaryItem.java b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/TemporaryItem.java new file mode 100644 index 000000000..760e29409 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/animation/slot/settings/item/TemporaryItem.java @@ -0,0 +1,53 @@ +package fr.maxlego08.menu.api.animation.slot.settings.item; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * An item that exists only while the opening animation plays. + * + *

A temporary item is not a button: it has no click, no requirement, and it is gone once the + * animation is over. It may be placed on a slot a button owns, in which case it hides that button + * until it is removed, and it may also be placed on a slot no button will ever occupy.

+ * + *

Its life is described by its keyframes. The common case of an item appearing once and going + * away once is written with {@code show} and {@code hide} in the configuration, and loaded as the + * two keyframes it stands for, so there is a single behaviour to reason about.

+ * + * @param name the name of the item in the configuration, used to report errors + * @param slots the slots the item occupies + * @param frames what the item does over time, in the order it was written + * @param chance the chance of the item appearing at all, in percent; rolled once when the + * animation is compiled, so an item that wins appears for all of its keyframes + * @param chancePerSlot whether the chance, and the choice of item, are rolled once for the whole + * item or separately for each of its slots + * @param count how many of its slots the item uses, picked at random; 0 uses all of them + * @param useCache whether the item may be served from its build cache, like the + * {@code use-cache} option of a button; false rebuilds it on every frame, + * which is what an item whose content changes while the animation plays needs + */ +public record TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance, boolean chancePerSlot, int count, boolean useCache) { + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance, boolean chancePerSlot, int count) { + this(name, slots, frames, chance, chancePerSlot, count, true); + } + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance, boolean chancePerSlot) { + this(name, slots, frames, chance, chancePerSlot, 0, true); + } + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames, float chance) { + this(name, slots, frames, chance, false, 0, true); + } + + public TemporaryItem(@NotNull String name, @NotNull List slots, @NotNull List frames) { + this(name, slots, frames, 100, false, 0, true); + } + + public TemporaryItem { + slots = List.copyOf(slots); + frames = List.copyOf(frames); + count = Math.max(0, count); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/annotations/AutoSlotAnimationLoader.java b/API/src/main/java/fr/maxlego08/menu/api/annotations/AutoSlotAnimationLoader.java new file mode 100644 index 000000000..2422f41de --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/annotations/AutoSlotAnimationLoader.java @@ -0,0 +1,17 @@ +package fr.maxlego08.menu.api.annotations; + +import fr.maxlego08.menu.api.loader.animation.slot.SlotAnimationLoader; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a {@link SlotAnimationLoader} of zMenu that is + * registered at startup. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface AutoSlotAnimationLoader { +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/button/Button.java b/API/src/main/java/fr/maxlego08/menu/api/button/Button.java index 0f174dda8..0cf02e2c4 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/button/Button.java +++ b/API/src/main/java/fr/maxlego08/menu/api/button/Button.java @@ -3,6 +3,8 @@ import fr.maxlego08.menu.api.Inventory; import fr.maxlego08.menu.api.MenuItemStack; import fr.maxlego08.menu.api.MenuPlugin; +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.idle.ButtonIdleAnimation; import fr.maxlego08.menu.api.engine.AnvilInventoryEngine; import fr.maxlego08.menu.api.engine.InventoryEngine; import fr.maxlego08.menu.api.engine.Pagination; @@ -60,6 +62,8 @@ public abstract class Button extends PlaceholderButton { private RefreshRequirement refreshRequirement; private int priority; // only use for convert DeluxeMenus config to zmenu object private boolean isInPlayerInventory; + private ButtonAnimationSettings animationSettings = ButtonAnimationSettings.DEFAULT; + private ButtonIdleAnimation idleAnimation; @Contract(pure = true) @Nullable @@ -551,6 +555,66 @@ public void setRefreshRequirement(@Nullable RefreshRequirement refreshRequiremen this.refreshRequirement = refreshRequirement; } + /** + * Returns how this button takes part in the opening animation of its inventory. + * + * @return the animation settings, never null + */ + @Contract(pure = true) + @NotNull + public ButtonAnimationSettings getAnimationSettings() { + if (this.animationSettings == ButtonAnimationSettings.DEFAULT && !this.allowsTemporaryItems()) { + return ButtonAnimationSettings.NOT_COVERABLE; + } + return this.animationSettings; + } + + /** + * Returns the animation this button plays on its own slots for as long as the menu stays open. + * + * @return the idle animation, or null if it has none + */ + @Contract(pure = true) + @Nullable + public ButtonIdleAnimation getIdleAnimation() { + return this.idleAnimation; + } + + /** + * Sets the animation this button plays on its own slots for as long as the menu stays open. + * + * @param idleAnimation the idle animation, or null for none + */ + public void setIdleAnimation(@Nullable ButtonIdleAnimation idleAnimation) { + this.idleAnimation = idleAnimation; + } + + /** + * Returns whether a temporary item of an animation may be drawn over the slots of this button. + * + *

Buttons refuse by default: an animation drawing over a button hides it, and on a button + * holding something a player can take it could hand out an item that was only ever decoration. + * A button that is safe to cover, or that a placeholder is useful on, overrides this.

+ * + *

This is the default the configuration starts from: a file may still set + * {@code animation.allow-temporary-items} either way for a button of its own.

+ * + * @return true if a temporary item may cover this button + */ + @Contract(pure = true) + public boolean allowsTemporaryItems() { + return false; + } + + /** + * Sets how this button takes part in the opening animation of its inventory. + * + * @param animationSettings the animation settings, null resets it to the default + */ + public void setAnimationSettings(@Nullable ButtonAnimationSettings animationSettings) { + this.animationSettings = animationSettings == null ? ButtonAnimationSettings.DEFAULT : animationSettings; + } + @Contract(pure = true) public int getPriority() { return this.priority; diff --git a/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java b/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java index cede50b54..faa7f6f1c 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java +++ b/API/src/main/java/fr/maxlego08/menu/api/button/buttons/ItemDragButton.java @@ -4,5 +4,18 @@ public abstract class ItemDragButton extends Button { + /** + * A drag slot is exactly where a placeholder is useful: it shows the player where an item is + * meant to go while the menu is still opening. + * + *

The items an animation draws are marked as belonging to the menu, so closing the inventory + * in the middle of one never hands them to the player.

+ * + * @return true + */ + @Override + public boolean allowsTemporaryItems() { + return true; + } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java b/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java index ce0f3fc17..afc551261 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java +++ b/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java @@ -105,6 +105,24 @@ public class Configuration { ) public static boolean enableFastEvent = false; + @ConfigOption( + type = DialogInputType.NUMBER_RANGE, + label = "Max animation duration in milliseconds", + startRange = 1000, + endRange = 60000, + stepRange = 500 + ) + public static int maxAnimationDuration = 10000; + + @ConfigOption( + type = DialogInputType.NUMBER_RANGE, + label = "Idle buttons before sharing one timer", + startRange = 0, + endRange = 54, + stepRange = 1 + ) + public static int sharedIdleTimerThreshold = 8; + // Seconds save player data: The time in seconds for automatic backup of player data. @ConfigOption( type = DialogInputType.NUMBER_RANGE, @@ -422,6 +440,8 @@ public void load(@NotNull FileConfiguration fileConfiguration) { allClicksType = clickTypes; enableCacheItemStack = fileConfiguration.getBoolean(ConfigPath.ENABLE_CACHE_ITEM_STACK.getPath()); + maxAnimationDuration = fileConfiguration.getInt(ConfigPath.MAX_ANIMATION_DURATION.getPath(), 10000); + sharedIdleTimerThreshold = Math.max(0, fileConfiguration.getInt(ConfigPath.SHARED_IDLE_TIMER_THRESHOLD.getPath(), 8)); enableCooldownClick = fileConfiguration.getBoolean(ConfigPath.ENABLE_COOLDOWN_CLICK.getPath()); cooldownClickMilliseconds = fileConfiguration.getLong(ConfigPath.COOLDOWN_CLICK_MILLISECONDS.getPath()); @@ -501,6 +521,8 @@ public void save(@NotNull FileConfiguration fileConfiguration, @NotNull File fil fileConfiguration.set(ConfigPath.ALL_CLICKS_TYPE.getPath(), clickTypeNames); fileConfiguration.set(ConfigPath.ENABLE_CACHE_ITEM_STACK.getPath(), enableCacheItemStack); + fileConfiguration.set(ConfigPath.MAX_ANIMATION_DURATION.getPath(), maxAnimationDuration); + fileConfiguration.set(ConfigPath.SHARED_IDLE_TIMER_THRESHOLD.getPath(), sharedIdleTimerThreshold); fileConfiguration.set(ConfigPath.ENABLE_COOLDOWN_CLICK.getPath(), enableCooldownClick); fileConfiguration.set(ConfigPath.COOLDOWN_CLICK_MILLISECONDS.getPath(), cooldownClickMilliseconds); fileConfiguration.set(ConfigPath.CACHE_PLACEHOLDER_API.getPath(), cachePlaceholderAPI); @@ -557,6 +579,8 @@ private enum ConfigPath { ALL_CLICKS_TYPE("all-clicks-type"), ENABLE_CACHE_ITEM_STACK("enable-cache-item-stack"), + MAX_ANIMATION_DURATION("max-animation-duration"), + SHARED_IDLE_TIMER_THRESHOLD("shared-idle-timer-threshold"), ENABLE_COOLDOWN_CLICK("enable-cooldown-click"), COOLDOWN_CLICK_MILLISECONDS("cooldown-click-milliseconds"), diff --git a/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java b/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java index c796081d2..142e9e9d9 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/engine/BaseInventory.java @@ -27,6 +27,16 @@ public interface BaseInventory extends InventoryHolder { @Contract(pure = true) boolean isClose(); + /** + * Returns whether this inventory currently ignores every click, for instance while its opening + * animation plays. + * + * @return true if the inventory is frozen + */ + default boolean isFrozen() { + return false; + } + @Contract("_, null -> null; _, !null -> !null") @Nullable ItemButton addItem(int slot, @Nullable ItemStack itemStack); diff --git a/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java b/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java index cb57ba27f..7202a2ea2 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java +++ b/API/src/main/java/fr/maxlego08/menu/api/engine/InventoryEngine.java @@ -1,6 +1,8 @@ package fr.maxlego08.menu.api.engine; import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; import fr.maxlego08.menu.api.button.Button; import fr.maxlego08.menu.api.utils.Placeholders; import fr.maxlego08.menu.zcore.utils.PerformanceDebug; @@ -64,4 +66,61 @@ public interface InventoryEngine extends BaseInventory { @Contract(pure = true) void cancel(int slot); + + /** + * Plays an animation on this inventory, now. + * + *

{@link AnimationPlayback#OVERLAY} draws the temporary items of the animation over the menu + * without hiding anything, which is what an already open inventory usually wants; + * {@link AnimationPlayback#REVEAL} hides the buttons and reveals them again, as opening the menu + * does. An overlay does not freeze the inventory, a reveal freezes it if the animation says so.

+ * + *

Only one animation plays at a time: starting another finishes the one in progress at once + * rather than queueing behind it.

+ * + * @param animation the animation to play + * @param playback how to play it + * @return how long the animation lasts, in milliseconds, or 0 if nothing was played + */ + default long playAnimation(@NotNull Animation animation, @NotNull AnimationPlayback playback) { + return 0; + } + + /** + * Plays an animation on this inventory, deciding whether it freezes the inventory. + * + * @param animation the animation to play + * @param playback how to play it + * @param freeze whether the inventory ignores clicks until the animation is over + * @return how long the animation lasts, in milliseconds, or 0 if nothing was played + */ + default long playAnimation(@NotNull Animation animation, @NotNull AnimationPlayback playback, boolean freeze) { + return 0; + } + + /** + * Finishes the animation currently playing, applying everything that is left at once. + */ + default void stopAnimation() { + } + + /** + * Runs something once the closing animation of this inventory is over. + * + *

This is how a menu closes itself gracefully. The window is not closed while the animation + * plays, because it cannot be: a client that has closed its inventory cannot be told to wait. + * Nothing has closed it yet, so the menu simply stays open until the buttons have been taken + * away, and only then does {@code whenDone} run.

+ * + *

It is only for a close the menu decides on, such as a {@code close} action or a switch to + * another menu. A player pressing escape closes the window immediately, and nothing can hold + * it open, so {@code whenDone} runs at once when there is no closing animation to play, when + * the inventory is already closed, or when the animation refuses to play.

+ * + * @param whenDone what to run once the menu is ready to go away. It runs at most once, and is + * skipped when the player closed the window themselves while it played + */ + default void closeWithAnimation(@NotNull Runnable whenDone) { + whenDone.run(); + } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/engine/OpenCause.java b/API/src/main/java/fr/maxlego08/menu/api/engine/OpenCause.java new file mode 100644 index 000000000..2b0c08bbe --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/engine/OpenCause.java @@ -0,0 +1,21 @@ +package fr.maxlego08.menu.api.engine; + +/** + * Why an inventory is being opened. + * + *

Passed to the engine among the opening arguments, so that it can tell a genuine open from a + * menu redrawing itself. Anything that re-opens the inventory a player is already looking at, such + * as a page change or a {@code refresh inventory} action, is a {@link #REFRESH}.

+ */ +public enum OpenCause { + + /** + * The player is opening the inventory, from closed or from another menu. + */ + OPEN, + + /** + * The inventory the player is already looking at is being redrawn. + */ + REFRESH +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationEndEvent.java b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationEndEvent.java new file mode 100644 index 000000000..89923bb33 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationEndEvent.java @@ -0,0 +1,65 @@ +package fr.maxlego08.menu.api.event.events; + +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.engine.InventoryEngine; +import fr.maxlego08.menu.api.event.MenuEvent; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +/** + * Called once an animation is over, whether it played to its end or was finished early because + * another one started, the inventory closed or the server stopped. + */ +public class AnimationEndEvent extends MenuEvent { + + private final Player player; + private final InventoryEngine inventory; + private final Animation animation; + private final AnimationPlayback playback; + private final boolean completed; + + public AnimationEndEvent(@NotNull Player player, @NotNull InventoryEngine inventory, + @NotNull Animation animation, @NotNull AnimationPlayback playback, boolean completed) { + this.player = player; + this.inventory = inventory; + this.animation = animation; + this.playback = playback; + this.completed = completed; + } + + @Contract(pure = true) + @NotNull + public Player getPlayer() { + return this.player; + } + + @Contract(pure = true) + @NotNull + public InventoryEngine getInventory() { + return this.inventory; + } + + @Contract(pure = true) + @NotNull + public Animation getAnimation() { + return this.animation; + } + + @Contract(pure = true) + @NotNull + public AnimationPlayback getPlayback() { + return this.playback; + } + + /** + * Returns whether every step was played in time, rather than the rest being applied at once. + * + * @return true if the animation reached its end on its own + */ + @Contract(pure = true) + public boolean isCompleted() { + return this.completed; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationStartEvent.java b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationStartEvent.java new file mode 100644 index 000000000..4ee3b29b6 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/event/events/AnimationStartEvent.java @@ -0,0 +1,74 @@ +package fr.maxlego08.menu.api.event.events; + +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.engine.InventoryEngine; +import fr.maxlego08.menu.api.event.CancelledMenuEvent; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +/** + * Called before an animation starts playing on an inventory. + * + *

Cancelling it stops the animation from playing at all; the inventory is displayed complete, as + * if it had none.

+ */ +public class AnimationStartEvent extends CancelledMenuEvent { + + private final Player player; + private final InventoryEngine inventory; + private final Animation animation; + private final AnimationPlayback playback; + private boolean freeze; + + public AnimationStartEvent(@NotNull Player player, @NotNull InventoryEngine inventory, + @NotNull Animation animation, @NotNull AnimationPlayback playback, boolean freeze) { + this.player = player; + this.inventory = inventory; + this.animation = animation; + this.playback = playback; + this.freeze = freeze; + } + + @Contract(pure = true) + @NotNull + public Player getPlayer() { + return this.player; + } + + @Contract(pure = true) + @NotNull + public InventoryEngine getInventory() { + return this.inventory; + } + + @Contract(pure = true) + @NotNull + public Animation getAnimation() { + return this.animation; + } + + @Contract(pure = true) + @NotNull + public AnimationPlayback getPlayback() { + return this.playback; + } + + /** + * @return whether the inventory will ignore clicks while the animation plays + */ + @Contract(pure = true) + public boolean isFreeze() { + return this.freeze; + } + + /** + * Changes whether the inventory ignores clicks while the animation plays. + * + * @param freeze true to freeze it + */ + public void setFreeze(boolean freeze) { + this.freeze = freeze; + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java b/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java index 363d822da..3139ec814 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java +++ b/API/src/main/java/fr/maxlego08/menu/api/inventory/ContainerInventory.java @@ -3,6 +3,7 @@ import fr.maxlego08.menu.api.Inventory; import fr.maxlego08.menu.api.MenuItemStack; import fr.maxlego08.menu.api.animation.TitleAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; import fr.maxlego08.menu.api.utils.ClearInvType; import org.bukkit.event.inventory.InventoryType; import org.jetbrains.annotations.Nullable; @@ -28,6 +29,16 @@ default boolean clearInventory() { @Nullable MenuItemStack getFillItemStack(); + /** + * Whether the fill item may be served from its build cache, the {@code use-cache} option of + * the fill item, which works like the one of a button. + * + * @return true when the cache may be used, which is the default + */ + default boolean isFillItemUseCache() { + return true; + } + boolean shouldCancelItemPickup(); InventoryType getType(); @@ -35,4 +46,28 @@ default boolean clearInventory() { boolean isClickLimiterEnabled(); ClearInvType getClearInvType(); + + /** + * Gets the animation played when this inventory is opened. + * + * @return the animation settings, or null if the inventory is displayed at once + */ + @Nullable + default SlotAnimationSettings getSlotAnimation() { + return null; + } + + /** + * Gets the animation played when this inventory closes itself. + * + *

Only plays for a close the menu decides on, such as a {@code close} action or a switch to + * another menu. A player pressing escape closes the window at once and nothing can hold it + * open long enough to animate.

+ * + * @return the animation settings, or null if the inventory just goes away + */ + @Nullable + default SlotAnimationSettings getCloseAnimation() { + return null; + } } diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/animation/slot/SlotAnimationLoader.java b/API/src/main/java/fr/maxlego08/menu/api/loader/animation/slot/SlotAnimationLoader.java new file mode 100644 index 000000000..f7c9904cf --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/animation/slot/SlotAnimationLoader.java @@ -0,0 +1,70 @@ +package fr.maxlego08.menu.api.loader.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Map; + +/** + * Builds a {@link SlotAnimation} from the options written next to the {@code type} of a group. + * It is what the slot animation registry holds, under its {@link #key() key} and its + * {@link #aliases() aliases}. + * + *

Most animations ignore those options entirely, but some, such as the one drawn with a + * matrix, are configured per inventory.

+ * + *

Register one with + * {@link fr.maxlego08.menu.api.InventoryManager#registerSlotAnimation(org.bukkit.plugin.Plugin, SlotAnimationLoader)}:

+ * + *
+ * public class MyAnimationLoader extends SlotAnimationLoader {
+ *     public MyAnimationLoader() {
+ *         super(Key.key("myplugin", "my_animation"));
+ *     }
+ *
+ *     public SlotAnimation create(Map<String, Object> options) {
+ *         return new MyAnimation();
+ *     }
+ * }
+ * 
+ */ +public abstract class SlotAnimationLoader implements Keyed { + + private final Key key; + private final List aliases; + + /** + * @param key the name menus use as {@code type} + * @param aliases other names for the same animation + */ + protected SlotAnimationLoader(@NotNull Key key, @NotNull Key... aliases) { + this.key = key; + this.aliases = List.of(aliases); + } + + @Override + public @NotNull Key key() { + return this.key; + } + + /** + * @return the other names of this animation + */ + public @NotNull List aliases() { + return this.aliases; + } + + /** + * Builds the animation. + * + * @param options the values of the section the {@code type} was read from + * @return the animation + * @throws IllegalArgumentException if the options are missing or invalid, with a message meant + * to be shown to whoever wrote the file + */ + @NotNull + public abstract SlotAnimation create(@NotNull Map options); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/KeyedRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/KeyedRegistry.java new file mode 100644 index 000000000..df2d71778 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/KeyedRegistry.java @@ -0,0 +1,54 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.stream.Stream; + +@ApiStatus.NonExtendable +public interface KeyedRegistry extends Iterable { + + @NotNull RegistryType type(); + + @NotNull Optional get(@NotNull Key id); + + default @NotNull T getOrThrow(@NotNull Key id) { + return this.get(id).orElseThrow(() -> new NoSuchElementException("No entry " + id.asString() + " in registry " + this.type())); + } + + boolean contains(@NotNull Key id); + + boolean containsValue(@NotNull T value); + + /** + * @return the name of what registered {@code id}, if it exists + */ + @NotNull Optional owner(@NotNull Key id); + + /** + * @return the other names of entries, from the alias to the entry it points to + */ + default @NotNull Map aliases() { + return Map.of(); + } + + @NotNull Set keys(); + + @NotNull List values(); + + default @NotNull Stream stream() { + return this.values().stream(); + } + + default int size() { + return this.values().size(); + } + + @Override + default @NotNull Iterator iterator() { + return this.values().iterator(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistrationException.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistrationException.java new file mode 100644 index 000000000..20cc2f5d5 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistrationException.java @@ -0,0 +1,14 @@ +package fr.maxlego08.menu.api.registry; + +import org.jetbrains.annotations.NotNull; + +/** + * Thrown when a value cannot be registered: the registry is frozen, the key is already taken, or + * the value is not of the type the registry holds. + */ +public final class RegistrationException extends RuntimeException { + + public RegistrationException(@NotNull String message) { + super(message); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/Registries.java b/API/src/main/java/fr/maxlego08/menu/api/registry/Registries.java new file mode 100644 index 000000000..cdb6d38fc --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/Registries.java @@ -0,0 +1,14 @@ +package fr.maxlego08.menu.api.registry; + +import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.ZMenuKeys; +import fr.maxlego08.menu.api.loader.animation.slot.SlotAnimationLoader; + +public final class Registries { + public static final RegistryType SLOT_ANIMATION = RegistryType.createStatic(ZMenuKeys.key("slot_animation"), SlotAnimationLoader.class); + + public static final RegistryType INVENTORY = RegistryType.createReloadable(ZMenuKeys.key("inventory"), Inventory.class); + + private Registries() { + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java index 2aa9bc7f4..c00358a48 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/Registry.java @@ -8,6 +8,11 @@ import java.util.Optional; import java.util.Set; +/** + * @deprecated a plain map with no owner and no duplicate detection. Use a {@link RegistryType} + * held by {@link RegistrySet} instead. + */ +@Deprecated public abstract class Registry { protected final Map registry = new HashMap<>(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryAccess.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryAccess.java new file mode 100644 index 000000000..348fc999d --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryAccess.java @@ -0,0 +1,21 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Optional; + +@ApiStatus.NonExtendable +public interface RegistryAccess { + /** + * @throws IllegalArgumentException if no such registry exists + */ + @NotNull KeyedRegistry registry(@NotNull RegistryType type); + + @NotNull Optional> find(@NotNull Key registryKey); + + @NotNull Collection> types(); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryKey.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryKey.java new file mode 100644 index 000000000..d62e3a64d --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryKey.java @@ -0,0 +1,23 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +public record RegistryKey(@NotNull RegistryType registry, @NotNull Key id) { + + public static @NotNull RegistryKey of(@NotNull RegistryType registry, @NotNull Key id) { + return new RegistryKey<>(registry, id); + } + + public @NotNull Optional resolve(@NotNull RegistryAccess access) { + return access.registry(this.registry).get(this.id); + } + + @Override + public @NotNull String toString() { + return this.registry + "/" + this.id.asString(); + } +} \ No newline at end of file diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryManager.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryManager.java new file mode 100644 index 000000000..23de8944d --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryManager.java @@ -0,0 +1,66 @@ +package fr.maxlego08.menu.api.registry; + +import fr.maxlego08.menu.api.registry.reload.ReloadReport; +import fr.maxlego08.menu.api.registry.reload.ReloadValidator; +import fr.maxlego08.menu.api.registry.reload.ReloadableProvider; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Every registry of zMenu. Read them as a {@link RegistryAccess}, and register your own entries + * with {@link #writable(RegistryType)}. + * + *

Get it with {@link fr.maxlego08.menu.api.InventoryManager#getRegistries()}. Static registries + * stop accepting entries once the server has finished loading, so register from your + * {@code onEnable}.

+ */ +@ApiStatus.NonExtendable +public interface RegistryManager extends RegistryAccess { + + /** + * Gets the write side of a registry. + * + * @param type the registry + * @param the type of its entries + * @return the registry, to register entries in + * @throws IllegalArgumentException if no such registry exists + */ + @NotNull WritableRegistry writable(@NotNull RegistryType type); + + /** + * Adds a provider to a reloadable registry. It runs on the next {@link #reload(RegistryType)}. + * + * @param type the reloadable registry + * @param owner the name of what provides, shown in reports + * @param provider the provider + * @throws IllegalArgumentException if no such reloadable registry exists + */ + void provider(@NotNull RegistryType type, @NotNull String owner, @NotNull ReloadableProvider provider); + + /** + * Adds a check run on every new snapshot, before it is published. + * + * @throws IllegalArgumentException if no such reloadable registry exists + */ + void validator(@NotNull RegistryType type, @NotNull String owner, @NotNull ReloadValidator validator); + + /** + * Rebuilds a reloadable registry from its providers. The new snapshot replaces the live one + * only if nothing reported an error; otherwise the previous snapshot stays live. + * + * @param type the reloadable registry + * @return what was found + * @throws IllegalArgumentException if no such reloadable registry exists + */ + @NotNull ReloadReport reload(@NotNull RegistryType type); + + /** + * Reloads every reloadable registry, in the order they were created. + * + * @return one report per registry + */ + @NotNull List reloadAll(); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryType.java b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryType.java new file mode 100644 index 000000000..fc5ed38ab --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/RegistryType.java @@ -0,0 +1,32 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +public record RegistryType(@NotNull Key key, @NotNull Class valueType, @NotNull Kind kind) implements Keyed { + + public enum Kind { + /** Filled during registration, then frozen for the server lifetime. */ + STATIC, + /** Rebuilt from providers on every reload and swapped atomically. */ + RELOADABLE + } + + public static @NotNull RegistryType createStatic(@NotNull Key key, @NotNull Class valueType) { + return new RegistryType<>(key, valueType, Kind.STATIC); + } + + public static @NotNull RegistryType createReloadable(@NotNull Key key, @NotNull Class valueType) { + return new RegistryType<>(key, valueType, Kind.RELOADABLE); + } + + public boolean isReloadable() { + return this.kind == Kind.RELOADABLE; + } + + @Override + public @NotNull String toString() { + return this.key.asString(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/WritableRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/WritableRegistry.java new file mode 100644 index 000000000..ff8205a37 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/WritableRegistry.java @@ -0,0 +1,44 @@ +package fr.maxlego08.menu.api.registry; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * The write side of a {@link KeyedRegistry}, used while plugins register their entries. + * + *

A static registry stops accepting entries once the server has finished loading, so register + * from your {@code onEnable}.

+ * + * @param the type of the entries + */ +@ApiStatus.NonExtendable +public interface WritableRegistry { + + @NotNull RegistryType type(); + + /** + * Registers an entry under its own {@link Keyed#key() key}. + * + * @param owner the name of what registers it, shown when two entries clash + * @param value the entry + * @return the entry + * @throws RegistrationException if the registry is frozen, the key is taken, or the value has the wrong type + */ + @NotNull V register(@NotNull String owner, @NotNull V value); + + default @NotNull V register(@NotNull Plugin owner, @NotNull V value) { + return this.register(owner.getName(), value); + } + + /** + * Makes {@code from} another name of the entry {@code to}. + * + * @throws RegistrationException if the registry is frozen, {@code from} is taken, or {@code to} does not exist + */ + void alias(@NotNull String owner, @NotNull Key from, @NotNull Key to); + + boolean contains(@NotNull Key id); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadContext.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadContext.java new file mode 100644 index 000000000..6e5a93593 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadContext.java @@ -0,0 +1,50 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.KeyedRegistry; +import fr.maxlego08.menu.api.registry.RegistryAccess; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Given to a {@link ReloadableProvider} while a new snapshot is being built. + * + * @param the type of the entries + */ +@ApiStatus.NonExtendable +public interface ReloadContext extends ReloadReporter { + + /** + * @return the name of the provider being run + */ + @NotNull String owner(); + + /** + * @return the registries, where the one being reloaded reads the snapshot under construction + */ + @NotNull RegistryAccess registries(); + + /** + * Adds an entry to the new snapshot. A key that is already taken is reported as an error. + * + * @param value the entry + * @return the entry + */ + @NotNull V register(@NotNull V value); + + /** + * Gets the snapshot that is live while this one is being built, to keep an entry whose file + * cannot be read anymore. + * + * @return the live snapshot + */ + @NotNull KeyedRegistry previous(); + + /** + * Runs an action once this snapshot has replaced the live one, such as firing a load event. + * The action never runs if the snapshot is rejected. + * + * @param action the action + */ + void onPublish(@NotNull Runnable action); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReport.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReport.java new file mode 100644 index 000000000..cc163b489 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReport.java @@ -0,0 +1,60 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.RegistryType; +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * What a reload of a reloadable registry found. + * + *

When there is an error, the new snapshot is rejected and the previous one stays live.

+ * + * @param type the registry that was reloaded + * @param issues everything providers and validators reported + * @param applied whether the new snapshot replaced the previous one + * @param generation the generation of the registry after the reload + */ +public record ReloadReport(@NotNull RegistryType type, @NotNull List issues, boolean applied, long generation) { + + public ReloadReport { + issues = List.copyOf(issues); + } + + public enum Severity { + WARNING, + ERROR + } + + /** + * @param severity how serious it is + * @param owner the provider or validator that reported it + * @param subject the entry it is about, if any + * @param message what is wrong, meant for whoever wrote the file + */ + public record Issue(@NotNull Severity severity, @NotNull String owner, @Nullable Key subject, @NotNull String message) { + } + + public boolean hasErrors() { + return this.issues.stream().anyMatch(issue -> issue.severity() == Severity.ERROR); + } + + public @NotNull List errors() { + return this.issues.stream().filter(issue -> issue.severity() == Severity.ERROR).toList(); + } + + public @NotNull List warnings() { + return this.issues.stream().filter(issue -> issue.severity() == Severity.WARNING).toList(); + } + + /** + * @return one line per issue, ready to be logged + */ + public @NotNull List format() { + return this.issues.stream() + .map(issue -> "[" + issue.severity() + "] " + issue.owner() + (issue.subject() == null ? "" : " " + issue.subject().asString()) + ": " + issue.message()) + .toList(); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReporter.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReporter.java new file mode 100644 index 000000000..c298ef6a7 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadReporter.java @@ -0,0 +1,30 @@ +package fr.maxlego08.menu.api.registry.reload; + +import net.kyori.adventure.key.Key; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Collects what goes wrong while a snapshot is being built. An error rejects the whole snapshot, + * a warning only gets reported. + */ +public interface ReloadReporter { + + void report(@NotNull ReloadReport.Severity severity, @Nullable Key subject, @NotNull String message); + + default void error(@NotNull String message) { + this.report(ReloadReport.Severity.ERROR, null, message); + } + + default void error(@NotNull Key subject, @NotNull String message) { + this.report(ReloadReport.Severity.ERROR, subject, message); + } + + default void warn(@NotNull String message) { + this.report(ReloadReport.Severity.WARNING, null, message); + } + + default void warn(@NotNull Key subject, @NotNull String message) { + this.report(ReloadReport.Severity.WARNING, subject, message); + } +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadValidator.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadValidator.java new file mode 100644 index 000000000..ce3b626ae --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadValidator.java @@ -0,0 +1,23 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.KeyedRegistry; +import fr.maxlego08.menu.api.registry.RegistryAccess; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +/** + * Checks a new snapshot once every provider has run, before it replaces the live one. This is the + * place for checks that need several entries, such as a menu opening another menu. + * + * @param the type of the entries + */ +@FunctionalInterface +public interface ReloadValidator { + + /** + * @param snapshot the new snapshot + * @param registries the registries, where the one being reloaded is {@code snapshot} + * @param reporter where to report what is wrong; an error rejects the snapshot + */ + void validate(@NotNull KeyedRegistry snapshot, @NotNull RegistryAccess registries, @NotNull ReloadReporter reporter); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableProvider.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableProvider.java new file mode 100644 index 000000000..4faaf9525 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableProvider.java @@ -0,0 +1,16 @@ +package fr.maxlego08.menu.api.registry.reload; + +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.NotNull; + +/** + * Contributes entries to a reloadable registry, typically by reading files. It runs on every + * reload, so it must not keep state between two runs. + * + * @param the type of the entries + */ +@FunctionalInterface +public interface ReloadableProvider { + + void provide(@NotNull ReloadContext context); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableRegistry.java new file mode 100644 index 000000000..9b4e35c24 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/registry/reload/ReloadableRegistry.java @@ -0,0 +1,19 @@ +package fr.maxlego08.menu.api.registry.reload; + +import fr.maxlego08.menu.api.registry.KeyedRegistry; +import net.kyori.adventure.key.Keyed; +import org.jetbrains.annotations.ApiStatus; + +/** + * A registry rebuilt from its providers on every reload. Reads always see one complete snapshot. + * + * @param the type of the entries + */ +@ApiStatus.NonExtendable +public interface ReloadableRegistry extends KeyedRegistry { + + /** + * @return how many snapshots were published, so that a cache can tell it is stale + */ + long generation(); +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java b/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java index 3f8e8c268..86c48cd3d 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/storage/StorageManager.java @@ -6,8 +6,10 @@ import fr.maxlego08.menu.api.storage.dto.InventoryDTO; import org.bukkit.event.Listener; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -41,4 +43,34 @@ public interface StorageManager extends Listener { Set getVisitedWorlds(@NotNull UUID playerId); void markWorldVisited(@NotNull UUID playerId, @NotNull String worldName); + + /** + * Reads back when a player last saw each animation that remembers itself. + * + *

This hits the database, so it must not be called from the main thread. It is meant to be + * loaded once when the player joins and kept in memory afterwards, because a menu opens far + * too often to ask the database each time.

+ * + * @param playerId the player + * @return the time each animation last played, in milliseconds since the epoch + */ + @NotNull + Map loadPlayedAnimations(@NotNull UUID playerId); + + /** + * Records that a player has just seen an animation. + * + * @param playerId the player + * @param animationKey the animation, as the inventory identifies it + * @param playedAt when it played, in milliseconds since the epoch + */ + void markAnimationPlayed(@NotNull UUID playerId, @NotNull String animationKey, long playedAt); + + /** + * Forgets that a player has seen an animation, so one that only plays once plays again. + * + * @param playerId the player + * @param animationKey the animation to forget, or null to forget all of them + */ + void clearPlayedAnimations(@NotNull UUID playerId, @Nullable String animationKey); } diff --git a/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java b/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java index 2791f3467..1abbe0103 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java +++ b/API/src/main/java/fr/maxlego08/menu/api/storage/Tables.java @@ -11,5 +11,6 @@ public interface Tables { @NotNull String PLAYER_DATAS = "%prefix%player_datas"; @NotNull String PLAYER_INVENTORIES = "%prefix%player_inventories"; @NotNull String FIRST_WORLD_JOIN = "%prefix%first_world_join"; + @NotNull String PLAYER_ANIMATIONS = "%prefix%player_animations"; } diff --git a/API/src/main/java/fr/maxlego08/menu/api/storage/dto/PlayerAnimationDTO.java b/API/src/main/java/fr/maxlego08/menu/api/storage/dto/PlayerAnimationDTO.java new file mode 100644 index 000000000..f617a09e2 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/menu/api/storage/dto/PlayerAnimationDTO.java @@ -0,0 +1,13 @@ +package fr.maxlego08.menu.api.storage.dto; + +import java.util.UUID; + +/** + * The last time a player saw one animation. + * + * @param playerId the player + * @param animationKey the animation, as the inventory identifies it + * @param playedAt when it last played, in milliseconds since the epoch + */ +public record PlayerAnimationDTO(UUID playerId, String animationKey, long playedAt) { +} diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java b/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java index e74809f1c..0b793a376 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/InventoryReplacement.java @@ -1,9 +1,9 @@ package fr.maxlego08.menu.api.utils; import fr.maxlego08.menu.api.Inventory; +import fr.maxlego08.menu.api.ZMenuKeys; import java.util.List; -import java.util.Objects; public record InventoryReplacement(String inventoryName, String plugin, List pages) { public InventoryReplacement(String inventoryName, String plugin, List pages) { @@ -14,12 +14,7 @@ public InventoryReplacement(String inventoryName, String plugin, List p public boolean shouldTrigger(Inventory inventory, int page) { - // Inventory Name - if (!Objects.equals(this.inventoryName, inventory.getFileName())) { - return false; - } - // Plugin Name - if (!Objects.equals(this.plugin, inventory.getPlugin().getName())) { + if (this.inventoryName == null || !inventory.key().equals(ZMenuKeys.fileKey(this.plugin, this.inventoryName))) { return false; } diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java b/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java index e6c3c7af6..d76b2e72e 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java @@ -94,6 +94,9 @@ public enum Message implements IMessage { GIVE_ITEM_SUCCESS_SELF("&aYou have received the item &f%itemId%&a."), GIVE_ITEM_SUCCESS_OTHER("&aYou have given the item &f%itemId%&a to &3%player%&a."), + ANIMATION_RESET_ALL("&aThe animations of &f%player% &awill all play again."), + ANIMATION_RESET_ONE("&aThe animation of &f%menu% &awill play again for &f%player%&a."), + PLAYERS_DATA_CLEAR_ALL("&aYou have just deleted the datas of all the players."), PLAYERS_DATA_CLEAR_PLAYER("&aYou have just deleted the player's data &f%player%&a."), diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayModeTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayModeTest.java new file mode 100644 index 000000000..fe33f0ee8 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationPlayModeTest.java @@ -0,0 +1,107 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.order.SequentialSlotAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationSeed; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the parsing of the {@code play} option, for its default, and for deciding whether + * an animation may play from what the player has already seen. + */ +class AnimationPlayModeTest { + + private static final long HOUR = 3_600_000L; + private static final long NOW = 1_000_000_000L; + + @Test + void modesAreParsedIgnoringCaseAndSeparators() { + assertEquals(AnimationPlayMode.ALWAYS, AnimationPlayMode.parse("ALWAYS", AnimationPlayMode.ONCE)); + assertEquals(AnimationPlayMode.ONCE, AnimationPlayMode.parse("once", AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ONCE, AnimationPlayMode.parse("First-Open", AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ONCE, AnimationPlayMode.parse("FIRST_OPEN", AnimationPlayMode.ALWAYS)); + } + + @Test + void anUnknownOrMissingModeFallsBackToTheDefault() { + assertEquals(AnimationPlayMode.ALWAYS, AnimationPlayMode.parse(null, AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ALWAYS, AnimationPlayMode.parse("sometimes", AnimationPlayMode.ALWAYS)); + } + + @Test + void settingsPlayOnEveryOpenByDefault() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + + assertEquals(AnimationPlayMode.ALWAYS, new SlotAnimationSettings(defaults, true).play()); + assertEquals(AnimationPlayMode.ALWAYS, new SlotAnimationSettings(defaults, java.util.Map.of(), true).play()); + } + + @Test + void theModeThatSurvivesAReconnectIsParsedToo() { + assertEquals(AnimationPlayMode.ONCE_FOREVER, AnimationPlayMode.parse("ONCE_FOREVER", AnimationPlayMode.ALWAYS)); + assertEquals(AnimationPlayMode.ONCE_FOREVER, AnimationPlayMode.parse("once-forever", AnimationPlayMode.ALWAYS)); + } + + @Test + void anAnimationNeverSeenAlwaysPlays() { + for (AnimationPlayMode mode : AnimationPlayMode.values()) { + assertTrue(mode.canPlay(null, NOW, 0), mode + " should play the first time"); + assertTrue(mode.canPlay(null, NOW, HOUR), mode + " should play the first time even under a cooldown"); + } + } + + @Test + void aOnceModeNeverPlaysTwice() { + assertFalse(AnimationPlayMode.ONCE.canPlay(NOW - 10 * HOUR, NOW, 0)); + assertFalse(AnimationPlayMode.ONCE_FOREVER.canPlay(NOW - 10 * HOUR, NOW, 0)); + assertFalse(AnimationPlayMode.ONCE_FOREVER.canPlay(NOW - 10 * HOUR, NOW, HOUR)); + } + + @Test + void alwaysReplaysUnlessItIsStillCoolingDown() { + AnimationPlayMode always = AnimationPlayMode.ALWAYS; + + assertTrue(always.canPlay(NOW - 10 * HOUR, NOW, 0), "no cooldown means no restriction"); + assertFalse(always.canPlay(NOW - HOUR / 2, NOW, HOUR), "half an hour into an hour long cooldown"); + assertTrue(always.canPlay(NOW - HOUR, NOW, HOUR), "the cooldown is over at exactly its length"); + assertTrue(always.canPlay(NOW - 2 * HOUR, NOW, HOUR)); + } + + @Test + void theModesKnowWhatTheyAre() { + assertTrue(AnimationPlayMode.ONCE_FOREVER.isPersistent()); + assertFalse(AnimationPlayMode.ONCE.isPersistent()); + assertFalse(AnimationPlayMode.ALWAYS.isPersistent()); + + assertTrue(AnimationPlayMode.ONCE.isOnce()); + assertTrue(AnimationPlayMode.ONCE_FOREVER.isOnce()); + assertFalse(AnimationPlayMode.ALWAYS.isOnce()); + } + + @Test + void settingsKnowWhenTheyNeedTheDatabase() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + + assertFalse(settings(defaults, AnimationPlayMode.ALWAYS, 0).needsPersistence()); + assertFalse(settings(defaults, AnimationPlayMode.ONCE, 0).needsPersistence()); + assertTrue(settings(defaults, AnimationPlayMode.ONCE_FOREVER, 0).needsPersistence()); + assertTrue(settings(defaults, AnimationPlayMode.ALWAYS, HOUR).needsPersistence()); + } + + @Test + void aNegativeCooldownIsNoCooldown() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + + assertEquals(0, settings(defaults, AnimationPlayMode.ALWAYS, -5).cooldown()); + } + + private static SlotAnimationSettings settings(SlotAnimationGroup defaults, AnimationPlayMode play, long cooldown) { + return new SlotAnimationSettings(defaults, java.util.Map.of(), true, play, java.util.List.of(), null, 100, + java.util.List.of(), AnimationSeed.RANDOM, java.util.List.of(), java.util.List.of(), cooldown); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationSlotsTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationSlotsTest.java new file mode 100644 index 000000000..7f56e6970 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/AnimationSlotsTest.java @@ -0,0 +1,63 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.animation.slot.order.SlotGrid; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the mapping between the menu, the player inventory, and the single space an + * animation is laid out over. + */ +class AnimationSlotsTest { + + private static final int SIZE = 54; + + @Test + void theMenuComesFirstAndThePlayerInventoryAfterIt() { + assertEquals(90, AnimationSlots.totalSize(SIZE)); + assertFalse(AnimationSlots.isPlayer(SIZE, 53)); + assertTrue(AnimationSlots.isPlayer(SIZE, 54)); + } + + @Test + void theStorageRowsAreLaidOutBeforeTheHotbar() { + assertEquals(54, AnimationSlots.fromPlayer(SIZE, 9)); + assertEquals(62, AnimationSlots.fromPlayer(SIZE, 17)); + assertEquals(80, AnimationSlots.fromPlayer(SIZE, 35)); + + assertEquals(81, AnimationSlots.fromPlayer(SIZE, 0)); + assertEquals(89, AnimationSlots.fromPlayer(SIZE, 8)); + } + + @Test + void theMappingGoesBothWays() { + for (int playerSlot = 0; playerSlot < AnimationSlots.PLAYER_SIZE; playerSlot++) { + int slot = AnimationSlots.fromPlayer(SIZE, playerSlot); + assertEquals(playerSlot, AnimationSlots.toPlayer(SIZE, slot)); + } + } + + @Test + void everyPlayerSlotGetsItsOwnPlaceInTheAnimation() { + Set slots = new HashSet<>(); + for (int playerSlot = 0; playerSlot < AnimationSlots.PLAYER_SIZE; playerSlot++) { + assertTrue(slots.add(AnimationSlots.fromPlayer(SIZE, playerSlot))); + } + assertEquals(AnimationSlots.PLAYER_SIZE, slots.size()); + } + + @Test + void theCombinedSpaceStaysAGridSoTheGridOrdersKeepWorking() { + assertEquals(9, SlotGrid.width(AnimationSlots.totalSize(SIZE))); + assertEquals(10, SlotGrid.height(AnimationSlots.totalSize(SIZE))); + + assertEquals(9, SlotGrid.width(AnimationSlots.totalSize(27))); + assertEquals(7, SlotGrid.height(AnimationSlots.totalSize(27))); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimationTest.java new file mode 100644 index 000000000..808684203 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/CustomSlotAnimationTest.java @@ -0,0 +1,53 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit tests for the animation whose order is listed slot by slot. + */ +class CustomSlotAnimationTest { + + private static final int SIZE = 27; + private static final List ALL_SLOTS = IntStream.range(0, SIZE).boxed().toList(); + + @Test + void slotsAreRevealedInTheOrderTheyAreListed() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(13, 4, 22, 12, 14)); + + assertIterableEquals(List.of(13, 4, 22, 12, 14), animation.order(SIZE, List.of(4, 12, 13, 14, 22))); + } + + @Test + void slotsThatAreNotListedComeLast() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(13, 4)); + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(13, 4), ordered.subList(0, 2)); + assertIterableEquals(List.of(0, 1, 2, 3, 5, 6), ordered.subList(2, 8)); + } + + @Test + void aSlotListedTwiceKeepsItsFirstPlace() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(5, 3, 5, 1)); + + assertIterableEquals(List.of(5, 3, 1), animation.order(SIZE, List.of(1, 3, 5))); + } + + @Test + void everySlotIsReturnedExactlyOnce() { + CustomSlotAnimation animation = new CustomSlotAnimation(List.of(26, 0, 13)); + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertEquals(SIZE, ordered.size()); + assertEquals(new HashSet<>(ALL_SLOTS), new HashSet<>(ordered)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimationTest.java new file mode 100644 index 000000000..e29fb4c82 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/MatrixSlotAnimationTest.java @@ -0,0 +1,67 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit tests for the animation whose order is drawn in a matrix. Reading the matrix option is tested with its loader. + */ +class MatrixSlotAnimationTest { + + private static final int SIZE = 27; + private static final List ALL_SLOTS = IntStream.range(0, SIZE).boxed().toList(); + + @Test + void everyCharacterIsAStepPlayedInCharacterOrder() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of( + "111111111", + "122222221", + "111111111")); + + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26), ordered.subList(0, 20)); + assertIterableEquals(List.of(10, 11, 12, 13, 14, 15, 16), ordered.subList(20, 27)); + } + + @Test + void charactersSortNaturallySoDigitsComeBeforeLetters() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("a1b2")); + + assertIterableEquals(List.of(1, 3, 0, 2), animation.order(SIZE, List.of(0, 1, 2, 3))); + } + + @Test + void slotsTheMatrixDoesNotCoverComeLast() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("11 11")); + + List ordered = animation.order(SIZE, ALL_SLOTS); + assertIterableEquals(List.of(0, 1, 7, 8), ordered.subList(0, 4)); + assertIterableEquals(List.of(2, 3, 4, 5, 6, 9, 10), ordered.subList(4, 11)); + } + + @Test + void aRowLongerThanTheGridIsTruncated() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("1111111112222")); + + assertIterableEquals(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8), animation.order(SIZE, ALL_SLOTS).subList(0, 9)); + } + + @Test + void everySlotIsReturnedExactlyOnce() { + MatrixSlotAnimation animation = new MatrixSlotAnimation(List.of("121212121", "212121212")); + List ordered = animation.order(SIZE, ALL_SLOTS); + + assertEquals(SIZE, ordered.size()); + assertEquals(new HashSet<>(ALL_SLOTS), new HashSet<>(ordered)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/SlotAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/SlotAnimationTest.java new file mode 100644 index 000000000..bed94df8f --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/order/SlotAnimationTest.java @@ -0,0 +1,150 @@ +package fr.maxlego08.menu.api.animation.slot.order; + +import fr.maxlego08.menu.api.animation.slot.SlotAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +/** + * Unit tests for the slot orderings used by the opening animation. They are pure arithmetic over a + * grid, so nothing here needs a server. + */ +class SlotAnimationTest { + + private static final int SIZE = 27; + private static final List ALL_SLOTS = IntStream.range(0, SIZE).boxed().toList(); + + @Test + void gridIsNineWideForEveryChestSize() { + assertEquals(9, SlotGrid.width(54)); + assertEquals(6, SlotGrid.height(54)); + assertEquals(9, SlotGrid.width(9)); + assertEquals(1, SlotGrid.height(9)); + } + + @Test + void gridFallsBackToASingleRowForOtherSizes() { + assertEquals(5, SlotGrid.width(5)); + assertEquals(1, SlotGrid.height(5)); + } + + @Test + void sequentialKeepsTheSlotOrder() { + assertIterableEquals(ALL_SLOTS, new SequentialSlotAnimation().order(SIZE, ALL_SLOTS)); + } + + @Test + void horizontalSnakeReversesEveryOtherRow() { + List expected = List.of( + 0, 1, 2, 3, 4, 5, 6, 7, 8, + 17, 16, 15, 14, 13, 12, 11, 10, 9, + 18, 19, 20, 21, 22, 23, 24, 25, 26); + assertIterableEquals(expected, new HorizontalSnakeSlotAnimation().order(SIZE, ALL_SLOTS)); + } + + @Test + void horizontalSnakeHandlesSparseSlots() { + assertIterableEquals(List.of(17, 9), new HorizontalSnakeSlotAnimation().order(SIZE, List.of(9, 17))); + } + + @Test + void verticalSnakeReversesEveryOtherColumn() { + List ordered = new VerticalSnakeSlotAnimation().order(SIZE, ALL_SLOTS); + assertIterableEquals(List.of(0, 9, 18, 19, 10, 1, 2, 11, 20), ordered.subList(0, 9)); + assertEquals(SIZE, ordered.size()); + } + + @Test + void spiralWalksTheGridClockwiseAndInwards() { + List expected = List.of( + 0, 1, 2, 3, 4, 5, 6, 7, 8, + 17, 26, + 25, 24, 23, 22, 21, 20, 19, 18, + 9, + 10, 11, 12, 13, 14, 15, 16); + assertIterableEquals(expected, new SpiralSlotAnimation().order(SIZE, ALL_SLOTS)); + } + + @Test + void fromCenterStartsAtTheMiddleSlot() { + List ordered = new FromCenterSlotAnimation().order(SIZE, ALL_SLOTS); + assertEquals(13, ordered.getFirst()); + assertEquals(new HashSet<>(List.of(0, 8, 18, 26)), new HashSet<>(ordered.subList(ordered.size() - 4, ordered.size()))); + } + + @Test + void waveSweepsAlongTheDiagonals() { + List ordered = new WaveSlotAnimation().order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 1, 9, 2, 10, 18), ordered.subList(0, 6)); + assertEquals(26, ordered.getLast()); + } + + @Test + void checkerboardFillsOneSlotOutOfTwoFirst() { + List ordered = new CheckerboardSlotAnimation().order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26), ordered.subList(0, 14)); + assertIterableEquals(List.of(1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25), ordered.subList(14, 27)); + } + + @Test + void columnsWalkEveryColumnInTheSameDirection() { + List ordered = new ColumnsSlotAnimation().order(SIZE, ALL_SLOTS); + + assertIterableEquals(List.of(0, 9, 18, 1, 10, 19, 2, 11, 20), ordered.subList(0, 9)); + } + + @Test + void rowsIsSequentialUnderAnotherName() { + assertIterableEquals(new SequentialSlotAnimation().order(SIZE, ALL_SLOTS), ALL_SLOTS); + } + + @Test + void everyAnimationReturnsEachSlotExactlyOnce() { + List sparse = List.of(0, 4, 8, 13, 18, 22, 26); + List animations = List.of( + new SequentialSlotAnimation(), + new HorizontalSnakeSlotAnimation(), + new VerticalSnakeSlotAnimation(), + new SpiralSlotAnimation(), + new FromCenterSlotAnimation(), + new WaveSlotAnimation(), + new CheckerboardSlotAnimation(), + new ColumnsSlotAnimation(), + new RandomSlotAnimation(new Random(42))); + + for (SlotAnimation animation : animations) { + List ordered = animation.order(SIZE, sparse); + assertEquals(sparse.size(), ordered.size(), animation.getClass().getSimpleName()); + assertEquals(new HashSet<>(sparse), new HashSet<>(ordered), animation.getClass().getSimpleName()); + } + } + + @Test + void groupSettingsClampNonSensicalValues() { + SlotAnimationGroup group = new SlotAnimationGroup(new SequentialSlotAnimation(), 0, 0, -5); + assertEquals(1, group.interval()); + assertEquals(1, group.slotsPerStep()); + assertEquals(0, group.delay()); + } + + @Test + void aGroupWithoutAnOverrideUsesTheDefaults() { + SlotAnimationGroup defaults = new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0); + SlotAnimationGroup second = new SlotAnimationGroup(new SpiralSlotAnimation(), 10, 1, 0); + SlotAnimationSettings settings = new SlotAnimationSettings(defaults, java.util.Map.of(2, second), true); + + assertEquals(defaults, settings.group(1)); + assertEquals(second, settings.group(2)); + assertEquals(defaults, settings.group(7)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchorTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchorTest.java new file mode 100644 index 000000000..da1925ddd --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationAnchorTest.java @@ -0,0 +1,83 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Unit tests for the anchors used to place things relative to the groups of an animation. + */ +class AnimationAnchorTest { + + @Test + void keywordsAreParsed() { + assertEquals(AnimationAnchor.START, AnimationAnchor.parse("start")); + assertEquals(AnimationAnchor.END, AnimationAnchor.parse("END")); + assertEquals(AnimationAnchor.START, AnimationAnchor.parse(" Start ")); + } + + @Test + void aPlainNumberIsATimeInMilliseconds() { + assertEquals(AnimationAnchor.at(250), AnimationAnchor.parse("250")); + assertEquals(AnimationAnchor.at(0), AnimationAnchor.parse("-40")); + } + + @Test + void groupBoundariesAreParsed() { + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parse("group:2")); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parse("group:2.end")); + assertEquals(AnimationAnchor.groupStart(10), AnimationAnchor.parse("GROUP:10")); + } + + @Test + void bothEndsOfAGroupCanBeWrittenExplicitly() { + assertEquals(AnimationAnchor.parse("group:2"), AnimationAnchor.parse("group:2.start")); + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parse("group:2.start")); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parse("GROUP:2.END")); + } + + @Test + void theExplicitSectionFormIsParsed() { + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parseValue(Map.of("group", 2))); + assertEquals(AnimationAnchor.groupStart(2), AnimationAnchor.parseValue(Map.of("group", 2, "at", "start"))); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parseValue(Map.of("group", 2, "at", "END"))); + assertEquals(AnimationAnchor.groupEnd(3), AnimationAnchor.parseValue(Map.of("group", "3", "at", "end"))); + } + + @Test + void withoutAGroupTheSectionFormAcceptsAnyCompactValue() { + assertEquals(AnimationAnchor.at(250), AnimationAnchor.parseValue(Map.of("at", 250))); + assertEquals(AnimationAnchor.START, AnimationAnchor.parseValue(Map.of("at", "start"))); + assertEquals(AnimationAnchor.groupEnd(2), AnimationAnchor.parseValue(Map.of("at", "group:2.end"))); + } + + @Test + void bothFormsAgree() { + assertEquals(AnimationAnchor.parse("group:2.end"), AnimationAnchor.parseValue(Map.of("group", 2, "at", "end"))); + assertEquals(AnimationAnchor.parse("250"), AnimationAnchor.parseValue(250)); + assertEquals(AnimationAnchor.parse("end"), AnimationAnchor.parseValue("end")); + } + + @Test + void anInvalidSectionIsRejectedRatherThanGuessed() { + assertNull(AnimationAnchor.parseValue(null)); + assertNull(AnimationAnchor.parseValue(Map.of())); + assertNull(AnimationAnchor.parseValue(Map.of("group", "x"))); + assertNull(AnimationAnchor.parseValue(Map.of("group", 2, "at", "middle"))); + assertNull(AnimationAnchor.parseValue(Map.of("at", "soon"))); + } + + @Test + void anInvalidAnchorIsRejectedRatherThanGuessed() { + assertNull(AnimationAnchor.parse(null)); + assertNull(AnimationAnchor.parse("")); + assertNull(AnimationAnchor.parse(" ")); + assertNull(AnimationAnchor.parse("group:x")); + assertNull(AnimationAnchor.parse("group:")); + assertNull(AnimationAnchor.parse("group:2.middle")); + assertNull(AnimationAnchor.parse("soon")); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeedTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeedTest.java new file mode 100644 index 000000000..f7acb6663 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/AnimationSeedTest.java @@ -0,0 +1,80 @@ +package fr.maxlego08.menu.api.animation.slot.settings; + +import fr.maxlego08.menu.api.animation.slot.order.SequentialSlotAnimation; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * Unit tests for where the randomness of an animation comes from. + */ +class AnimationSeedTest { + + private static final UUID PLAYER = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID OTHER = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + + @Test + void theFormsAreParsed() { + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse(null)); + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse("random")); + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse(" ")); + assertEquals(AnimationSeed.Type.PLAYER, AnimationSeed.parse("PLAYER").type()); + assertEquals(new AnimationSeed(AnimationSeed.Type.FIXED, 42), AnimationSeed.parse("42")); + } + + @Test + void anythingElseRollsAfresh() { + assertEquals(AnimationSeed.RANDOM, AnimationSeed.parse("sometimes")); + } + + @Test + void aPlayerAlwaysGetsTheSameAnimation() { + AnimationSeed seed = AnimationSeed.parse("player"); + + assertEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(PLAYER, "pulse"))); + } + + @Test + void twoPlayersGetDifferentOnes() { + AnimationSeed seed = AnimationSeed.parse("player"); + + assertNotEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(OTHER, "pulse"))); + } + + @Test + void twoAnimationsDoNotEndUpWithTheSamePattern() { + AnimationSeed seed = AnimationSeed.parse("player"); + + assertNotEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(PLAYER, "sparkle"))); + } + + @Test + void aFixedSeedIsTheSameForEveryone() { + AnimationSeed seed = AnimationSeed.parse("7"); + + assertEquals(draw(seed.create(PLAYER, "pulse")), draw(seed.create(OTHER, "pulse"))); + } + + @Test + void aPlayerSeedFallsBackWhenThereIsNoPlayer() { + AnimationSeed seed = AnimationSeed.parse("player"); + assertEquals(10, draw(seed.create(null, "pulse")).size()); + } + + @Test + void anAnimationRollsAfreshByDefault() { + SlotAnimationSettings settings = new SlotAnimationSettings( + new SlotAnimationGroup(new SequentialSlotAnimation(), 50, 1, 0), true); + + assertEquals(AnimationSeed.RANDOM, settings.seed()); + } + + private static List draw(Random random) { + return java.util.stream.IntStream.range(0, 10).map(index -> random.nextInt(1000)).boxed().toList(); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimationTest.java b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimationTest.java new file mode 100644 index 000000000..2fb8e4224 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/animation/slot/settings/idle/ButtonIdleAnimationTest.java @@ -0,0 +1,111 @@ +package fr.maxlego08.menu.api.animation.slot.settings.idle; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.TestMenuItemStacks; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the animation a button plays on its own slots while the menu stays open. + */ +class ButtonIdleAnimationTest { + + private static final MenuItemStack FIRST = TestMenuItemStacks.named("first"); + private static final MenuItemStack SECOND = TestMenuItemStacks.named("second"); + + @Test + void aLoopingCycleNeverEnds() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, true); + + assertSame(FIRST, animation.item(0)); + assertSame(SECOND, animation.item(1)); + assertSame(FIRST, animation.item(2)); + assertSame(SECOND, animation.item(101)); + + assertFalse(animation.isOver(0)); + assertFalse(animation.isOver(1000)); + } + + @Test + void aCycleThatDoesNotLoopIsOverAfterItsLastItem() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, false); + + assertFalse(animation.isOver(0)); + assertFalse(animation.isOver(1)); + assertTrue(animation.isOver(2)); + } + + @Test + void theIntervalIsClamped() { + assertEquals(1, ButtonIdleAnimation.of(List.of(FIRST), 0, true).interval()); + assertEquals(1, ButtonIdleAnimation.of(List.of(FIRST), -50, true).interval()); + } + + @Test + void aRandomCycleIgnoresTheTurnAndPicks() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, true, true); + Random random = new Random(4); + + Set seen = new HashSet<>(); + for (int turn = 0; turn < 50; turn++) { + seen.add(animation.item(0, random)); + } + assertEquals(2, seen.size()); + } + + @Test + void aCycleThatIsNotRandomStillFollowsItsOrder() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST, SECOND), 200, true, false); + Random random = new Random(4); + + assertSame(FIRST, animation.item(0, random)); + assertSame(SECOND, animation.item(1, random)); + assertSame(FIRST, animation.item(2, random)); + } + + @Test + void aFrameWithoutADurationFallsBackToTheInterval() { + IdleFrame held = new IdleFrame(FIRST, 1000, null); + IdleFrame plain = new IdleFrame(SECOND); + + assertEquals(1000, held.duration(200)); + assertEquals(200, plain.duration(200)); + } + + @Test + void aNegativeDurationIsTreatedAsNone() { + assertEquals(0, new IdleFrame(FIRST, -50, null).duration()); + assertEquals(200, new IdleFrame(FIRST, -50, null).duration(200)); + } + + @Test + void framesKeepTheirOwnDurationAndSound() { + IdleFrame held = new IdleFrame(FIRST, 1000, null); + IdleFrame flash = new IdleFrame(SECOND, 50, null); + ButtonIdleAnimation animation = new ButtonIdleAnimation(List.of(held, flash), 200, true); + + assertSame(held, animation.frame(0)); + assertSame(flash, animation.frame(1)); + assertSame(held, animation.frame(2)); + assertSame(FIRST, animation.item(0)); + assertIterableEquals(List.of(FIRST, SECOND), animation.items()); + } + + @Test + void aSingleItemCycleAlwaysShowsIt() { + ButtonIdleAnimation animation = ButtonIdleAnimation.of(List.of(FIRST), 200, true); + + assertSame(FIRST, animation.item(0)); + assertSame(FIRST, animation.item(7)); + } +} diff --git a/API/src/test/java/fr/maxlego08/menu/api/button/ButtonTemporaryItemsTest.java b/API/src/test/java/fr/maxlego08/menu/api/button/ButtonTemporaryItemsTest.java new file mode 100644 index 000000000..539d83718 --- /dev/null +++ b/API/src/test/java/fr/maxlego08/menu/api/button/ButtonTemporaryItemsTest.java @@ -0,0 +1,80 @@ +package fr.maxlego08.menu.api.button; + +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.button.buttons.ItemDragButton; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the class level refusal of temporary items. + */ +class ButtonTemporaryItemsTest { + + @Test + void aButtonRefusesToBeCoveredByDefault() { + Button button = new PlainButton(); + + assertFalse(button.allowsTemporaryItems()); + assertFalse(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void aDragButtonOptsIn() { + Button button = new ItemDragButton() { + }; + + assertTrue(button.allowsTemporaryItems()); + assertTrue(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void aButtonBuiltInCodeFollowsItsOwnClass() { + assertFalse(new PlainButton().getAnimationSettings().allowTemporaryItems()); + assertTrue(new CoverableButton().getAnimationSettings().allowTemporaryItems()); + } + + @Test + void theConfigurationMayAllowAButtonThatWouldHaveRefused() { + Button button = new PlainButton(); + button.setAnimationSettings(new ButtonAnimationSettings(true, 1, 0, null, true)); + + assertTrue(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void theConfigurationMayRefuseAButtonThatWouldHaveAllowed() { + Button button = new CoverableButton(); + button.setAnimationSettings(new ButtonAnimationSettings(true, 1, 0, null, false)); + + assertTrue(button.allowsTemporaryItems()); + assertFalse(button.getAnimationSettings().allowTemporaryItems()); + } + + @Test + void theOtherAnimationSettingsAreUntouched() { + Button button = new PlainButton(); + button.setAnimationSettings(new ButtonAnimationSettings(true, 3, 250, null, true)); + + ButtonAnimationSettings settings = button.getAnimationSettings(); + assertTrue(settings.allowTemporaryItems()); + assertTrue(settings.enabled()); + assertEquals(3, settings.group()); + assertEquals(250, settings.delay()); + } + + private static final class PlainButton extends Button { + } + + /** + * A button that is safe to draw over, and says so. + */ + private static final class CoverableButton extends Button { + @Override + public boolean allowsTemporaryItems() { + return true; + } + } +} diff --git a/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestMenuItemStacks.java b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestMenuItemStacks.java new file mode 100644 index 000000000..b657f6082 --- /dev/null +++ b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestMenuItemStacks.java @@ -0,0 +1,27 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.MenuItemStack; + +import java.lang.reflect.Proxy; + +/** + * Creates {@link MenuItemStack} stand-ins for the compiler tests. The compiler only ever carries + * them around, it never builds them, so a proxy that answers nothing is enough. + */ +public final class TestMenuItemStacks { + + private TestMenuItemStacks() { + } + + public static MenuItemStack named(String name) { + return (MenuItemStack) Proxy.newProxyInstance( + TestMenuItemStacks.class.getClassLoader(), + new Class[]{MenuItemStack.class}, + (proxy, method, args) -> switch (method.getName()) { + case "toString" -> name; + case "hashCode" -> name.hashCode(); + case "equals" -> proxy == args[0]; + default -> null; + }); + } +} diff --git a/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestSounds.java b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestSounds.java new file mode 100644 index 000000000..b3c72b2d6 --- /dev/null +++ b/API/src/testFixtures/java/fr/maxlego08/menu/api/animation/slot/TestSounds.java @@ -0,0 +1,32 @@ +package fr.maxlego08.menu.api.animation.slot; + +import fr.maxlego08.menu.api.sound.SoundOption; + +import java.lang.reflect.Proxy; + +/** + * A {@link SoundOption} stand-in for the compiler tests, which only ever carry it around. + */ +public final class TestSounds { + + public static final SoundOption SILENT = named("silent"); + + public static SoundOption other() { + return named("other"); + } + + public static SoundOption named(String name) { + return (SoundOption) Proxy.newProxyInstance( + TestSounds.class.getClassLoader(), + new Class[]{SoundOption.class}, + (proxy, method, args) -> switch (method.getName()) { + case "toString" -> name; + case "hashCode" -> name.hashCode(); + case "equals" -> proxy == args[0]; + default -> null; + }); + } + + private TestSounds() { + } +} diff --git a/Common/build.gradle.kts b/Common/build.gradle.kts index b7ee220d2..c4e953910 100644 --- a/Common/build.gradle.kts +++ b/Common/build.gradle.kts @@ -2,4 +2,6 @@ dependencies { api(projects.api) api(projects.nms.base) compileOnly(libs.paper.api) + testImplementation(libs.paper.api) + testImplementation(testFixtures(projects.api)) } \ No newline at end of file diff --git a/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java b/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java index 16a552913..c894a443b 100644 --- a/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java +++ b/Common/src/main/java/fr/maxlego08/menu/ZMenuItemStack.java @@ -1043,33 +1043,49 @@ public void setTypeMapAccessor(MapConfiguration configuration) { } private Color getColor(MapConfiguration configuration, String key, Color def) { - String[] split = configuration.getString(key, "").split(","); - - if (split.length == 3) { - return Color.fromRGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2])); - } else if (split.length == 4) { - return Color.fromARGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2]), Integer.parseInt(split[3])); - } - - return def; + Color color = parseColor(configuration.getString(key, ""), key); + return color == null ? def : color; } private List getColors(MapConfiguration section, String key) { List colors = new ArrayList<>(); for (String color : section.getStringList(key)) { - String[] split = color.split(","); - - if (split.length == 3) { - colors.add(Color.fromRGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2]))); - } else if (split.length == 4) { - colors.add(Color.fromARGB(Integer.parseInt(split[0]), Integer.parseInt(split[1]), Integer.parseInt(split[2]), Integer.parseInt(split[3]))); - } + Color parsed = parseColor(color, key); + if (parsed != null) colors.add(parsed); } return colors; } + /** + * Parses an {@code r,g,b} or {@code a,r,g,b} colour. + *

+ * Neither the number parsing nor {@link Color#fromRGB(int, int, int)}, which rejects anything + * outside 0-255, may throw here: a single typo would otherwise abort the whole item build. + * + * @return the colour, or {@code null} when the value is missing or malformed. + */ + private static Color parseColor(String value, String key) { + if (value == null || value.isBlank()) return null; + + String[] split = value.split(","); + if (split.length != 3 && split.length != 4) return null; + + try { + int[] parts = new int[split.length]; + for (int index = 0; index != split.length; index++) { + parts[index] = Integer.parseInt(split[index].trim()); + } + return split.length == 3 + ? Color.fromRGB(parts[0], parts[1], parts[2]) + : Color.fromARGB(parts[0], parts[1], parts[2], parts[3]); + } catch (IllegalArgumentException exception) { + Logger.info("Color " + value + " is not valid at " + key + ", expected numbers between 0 and 255.", Logger.LogType.ERROR); + return null; + } + } + public Map getTranslatedDisplayName() { return this.translatedDisplayName; } diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationCompiler.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationCompiler.java new file mode 100644 index 000000000..2db283aaf --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationCompiler.java @@ -0,0 +1,369 @@ +package fr.maxlego08.menu.animation.compile; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.animation.slot.AnimationContext; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.item.AnimationKeyframe; +import fr.maxlego08.menu.api.animation.slot.settings.item.TemporaryItem; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Turns the animation configuration of an inventory into a flat, sorted list of {@link AnimationStep}. + * + *

Compilation happens in two passes. The first one lays the groups out one after the other and + * records where each of them starts and ends. The second one resolves the {@link AnimationAnchor} of + * everything that is positioned relative to those boundaries. All of the timing is therefore decided + * before anything is displayed, and the engine only has to apply the steps whose time has come.

+ * + *

Steps carry an absolute time rather than a delay from the previous one, so the passes never + * have to know about each other: whatever they produce is merged by sorting.

+ */ +public final class AnimationCompiler { + + private AnimationCompiler() { + } + + /** + * Compiles the animation of an inventory. + * + * @param inventorySize the size of the inventory + * @param settings the animation settings of the inventory + * @param filledSlots the slots holding an item, in ascending order + * @param slotSettings the per slot configuration, slots missing from the map use + * {@link ButtonAnimationSettings#DEFAULT} + * @return the compiled animation, empty if nothing is animated + */ + @NotNull + public static CompiledAnimation compile(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings) { + return compile(inventorySize, settings, filledSlots, slotSettings, AnimationPlayback.REVEAL); + } + + /** + * Compiles the animation of an inventory for a given way of playing it. + * + * @param inventorySize the size of the inventory + * @param settings the animation settings of the inventory + * @param filledSlots the slots holding an item, in ascending order + * @param slotSettings the per slot configuration, slots missing from the map use + * {@link ButtonAnimationSettings#DEFAULT} + * @param playback whether the buttons are revealed, or only drawn over + * @return the compiled animation, empty if nothing is animated + */ + @NotNull + public static CompiledAnimation compile(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings, + @NotNull AnimationPlayback playback) { + return compile(inventorySize, settings, filledSlots, slotSettings, playback, null); + } + + /** + * Compiles the animation of an inventory, telling the orderings who it is played for. + * + * @param inventorySize the size of the inventory + * @param settings the animation settings of the inventory + * @param filledSlots the slots holding an item, in ascending order + * @param slotSettings the per slot configuration, slots missing from the map use + * {@link ButtonAnimationSettings#DEFAULT} + * @param playback whether the buttons are revealed, or only drawn over + * @param context the player and the menu the animation is played for, null outside of one + * @return the compiled animation, empty if nothing is animated + */ + @NotNull + public static CompiledAnimation compile(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings, + @NotNull AnimationPlayback playback, + @Nullable AnimationContext context) { + + AnimationContext animationContext = context == null + ? new AnimationContext(inventorySize, filledSlots, null, null) + : context; + + List steps = new ArrayList<>(); + Map revealTimes = new LinkedHashMap<>(); + AnimationTiming timing = revealButtons(inventorySize, settings, filledSlots, slotSettings, steps, revealTimes, animationContext, playback); + + if (playback == AnimationPlayback.OVERLAY) steps.clear(); + + if (playback == AnimationPlayback.CONCEAL) { + steps.replaceAll(step -> step.action() == StepAction.REVEAL + ? new AnimationStep(step.time(), step.slot(), StepAction.CLEAR, null, step.sound(), step.useCache()) + : step); + revealTimes.clear(); + } + + applyTemporaryItems(inventorySize, settings, timing, steps, revealTimes, slotSettings, playback, animationContext); + + if (steps.isEmpty()) return CompiledAnimation.EMPTY; + + steps.sort(Comparator.comparingLong(AnimationStep::time).thenComparingInt(AnimationStep::slot)); + return new CompiledAnimation(dedupeSounds(steps), timing); + } + + /** + * First pass: lays the groups out and emits one {@link StepAction#REVEAL} per animated slot. + * + * @return where every group starts and ends + */ + @NotNull + private static AnimationTiming revealButtons(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull List filledSlots, + @NotNull Map slotSettings, + @NotNull List steps, + @NotNull Map revealTimes, + @NotNull AnimationContext context, + @NotNull AnimationPlayback playback) { + + boolean honourOptOut = playback != AnimationPlayback.CONCEAL; + + Map> slotsByGroup = new TreeMap<>(); + for (int slot : filledSlots) { + ButtonAnimationSettings slotSetting = slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT); + if (honourOptOut && !slotSetting.enabled()) continue; + slotsByGroup.computeIfAbsent(slotSetting.group(), key -> new ArrayList<>()).add(slot); + } + + Map groups = new LinkedHashMap<>(); + long groupStart = 0; + + for (Map.Entry> entry : slotsByGroup.entrySet()) { + SlotAnimationGroup group = settings.group(entry.getKey()); + List ordered = new ArrayList<>(group.animation().order(context.withSlots(entry.getValue()))); + if (group.reverse()) Collections.reverse(ordered); + + long start = groupStart + group.delay(); + long groupEnd = start; + + for (int index = 0; index < ordered.size(); index++) { + int slot = ordered.get(index); + long buttonDelay = slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT).delay(); + long time = start + ((long) (index / group.slotsPerStep()) * group.interval()) + buttonDelay; + if (group.jitter() > 0) { + time = Math.max(0, time + context.random().nextLong(-group.jitter(), group.jitter() + 1)); + } + + ButtonAnimationSettings slotSetting = slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT); + SoundOption sound = slotSetting.sound() != null ? slotSetting.sound() : group.sound(); + + steps.add(new AnimationStep(time, slot, StepAction.REVEAL, null, sound)); + revealTimes.put(slot, time); + groupEnd = Math.max(groupEnd, time + group.interval()); + } + + groups.put(entry.getKey(), new GroupTiming(start, groupEnd)); + + groupStart = groupEnd; + } + + return new AnimationTiming(groups, groupStart); + } + + /** + * Second pass: places the items that only exist while the animation plays. + * + *

Every temporary item is a list of keyframes. A keyframe holding an item displays it from + * then on, a keyframe without one removes it. An item still shown when the last group is over is + * removed there, so an animation can never leak an item into the finished menu.

+ * + *

Items are resolved per slot rather than one after the other, because several of them may + * cover the same slot: at any moment the one declared last wins, and a step is only emitted when + * what the slot displays actually changes.

+ * + *

A temporary item wins over the button owning its slot for as long as it is shown. When the + * last of them goes away the slot is not blanked but re-resolved: it goes back to the real item + * if the button would have been revealed by then, and is emptied otherwise. Because the reveal + * times are already known, that decision is taken here rather than at playback.

+ */ + private static void applyTemporaryItems(int inventorySize, + @NotNull SlotAnimationSettings settings, + @NotNull AnimationTiming timing, + @NotNull List steps, + @NotNull Map revealTimes, + @NotNull Map slotSettings, + @NotNull AnimationPlayback playback, + @NotNull AnimationContext context) { + + if (settings.temporaryItems().isEmpty()) return; + + Map> keyframesBySlot = new TreeMap<>(); + + for (int index = 0; index < settings.temporaryItems().size(); index++) { + TemporaryItem temporaryItem = settings.temporaryItems().get(index); + if (temporaryItem.frames().isEmpty()) continue; + + boolean perSlot = temporaryItem.chancePerSlot(); + if (!perSlot && !context.roll(temporaryItem.chance())) continue; + + List shared = perSlot ? null : resolveKeyframes(temporaryItem, timing, context); + if (shared != null && shared.isEmpty()) continue; + + List eligible = new ArrayList<>(); + for (int slot : temporaryItem.slots()) { + if (slot < 0 || slot >= inventorySize) continue; + if (!slotSettings.getOrDefault(slot, ButtonAnimationSettings.DEFAULT).allowTemporaryItems()) continue; + eligible.add(slot); + } + + for (int slot : context.pickSome(eligible, temporaryItem.count())) { + if (perSlot && !context.roll(temporaryItem.chance())) continue; + + List frames = shared != null ? shared : resolveKeyframes(temporaryItem, timing, context); + if (frames.isEmpty()) continue; + + List slotKeyframes = keyframesBySlot.computeIfAbsent(slot, key -> new ArrayList<>()); + for (ResolvedKeyframe frame : frames) { + slotKeyframes.add(new SlotKeyframe(frame.time(), index, frame.item(), frame.sound(), temporaryItem.useCache())); + } + } + } + if (keyframesBySlot.isEmpty()) return; + + dropRepeatedSounds(keyframesBySlot); + + List temporarySteps = new ArrayList<>(); + Set hiddenReveals = new HashSet<>(); + + keyframesBySlot.forEach((slot, keyframes) -> { + keyframes.sort(Comparator.comparingLong(SlotKeyframe::time)); + + TreeMap shown = new TreeMap<>(); + Map shownUseCache = new TreeMap<>(); + MenuItemStack displayed = null; + long coverStart = 0; + Long reveal = revealTimes.get(slot); + + for (int index = 0; index < keyframes.size(); index++) { + SlotKeyframe keyframe = keyframes.get(index); + + if (keyframe.item() == null) { + shown.remove(keyframe.owner()); + shownUseCache.remove(keyframe.owner()); + } else { + shown.put(keyframe.owner(), keyframe.item()); + shownUseCache.put(keyframe.owner(), keyframe.useCache()); + } + + if (index + 1 < keyframes.size() && keyframes.get(index + 1).time() == keyframe.time()) continue; + + Integer topOwner = shown.isEmpty() ? null : shown.lastKey(); + MenuItemStack top = topOwner == null ? null : shown.get(topOwner); + if (top == displayed) continue; + + if (top != null) { + if (displayed == null) coverStart = keyframe.time(); + boolean useCache = shownUseCache.getOrDefault(topOwner, true); + temporarySteps.add(new AnimationStep(keyframe.time(), slot, StepAction.SET, top, keyframe.sound(), useCache)); + } else { + if (reveal != null && reveal >= coverStart && reveal <= keyframe.time()) hiddenReveals.add(slot); + StepAction action = playback == AnimationPlayback.OVERLAY || (reveal != null && reveal <= keyframe.time()) + ? StepAction.REVEAL + : StepAction.CLEAR; + temporarySteps.add(new AnimationStep(keyframe.time(), slot, action, null, keyframe.sound())); + } + displayed = top; + } + }); + + if (!hiddenReveals.isEmpty()) { + steps.removeIf(step -> step.action() == StepAction.REVEAL && hiddenReveals.contains(step.slot())); + } + steps.addAll(temporarySteps); + } + + /** + * Keeps a keyframe sound on the first slot it lands on only, so an item covering nine slots does + * not play the same sound nine times. + */ + private static void dropRepeatedSounds(@NotNull Map> keyframesBySlot) { + Set seen = new HashSet<>(); + + keyframesBySlot.values().forEach(keyframes -> keyframes.replaceAll(keyframe -> { + if (keyframe.sound() == null) return keyframe; + return seen.add(keyframe.owner() + "@" + keyframe.time()) + ? keyframe + : new SlotKeyframe(keyframe.time(), keyframe.owner(), keyframe.item(), null, keyframe.useCache()); + })); + } + + /** + * A keyframe of one temporary item, applied to one slot. {@code owner} is the position of the + * item in the configuration, which decides who wins when two of them cover the same slot. + */ + private record SlotKeyframe(long time, int owner, MenuItemStack item, SoundOption sound, boolean useCache) { + } + + /** + * Resolves the keyframes of a temporary item to times, in the order they happen, and appends the + * removal of an item that is never removed explicitly. + */ + @NotNull + private static List resolveKeyframes(@NotNull TemporaryItem temporaryItem, @NotNull AnimationTiming timing, + @NotNull AnimationContext context) { + List frames = new ArrayList<>(temporaryItem.frames().size() + 1); + for (AnimationKeyframe keyframe : temporaryItem.frames()) { + if (!keyframe.isRemoval() && !context.roll(keyframe.chance())) continue; + + MenuItemStack item = keyframe.choices().isEmpty() ? keyframe.item() : context.pick(keyframe.choices()); + frames.add(new ResolvedKeyframe(timing.resolve(keyframe.at()), item, keyframe.sound())); + } + if (frames.isEmpty()) return frames; + + frames.sort(Comparator.comparingLong(ResolvedKeyframe::time)); + + ResolvedKeyframe last = frames.getLast(); + if (last.item() != null) { + frames.add(new ResolvedKeyframe(Math.max(timing.end(), last.time()), null, null)); + } + return frames; + } + + private record ResolvedKeyframe(long time, MenuItemStack item, SoundOption sound) { + } + + /** + * Keeps at most one sound per instant. + * + *

A step revealing nine slots at once, or a keyframe landing on the same instant as a reveal, + * would otherwise play the same sound several times in the same tick, which is heard as noise + * rather than as an effect.

+ */ + @NotNull + private static List dedupeSounds(@NotNull List steps) { + List deduped = new ArrayList<>(steps.size()); + + long currentTime = Long.MIN_VALUE; + boolean playedAtCurrentTime = false; + + for (AnimationStep step : steps) { + if (step.time() != currentTime) { + currentTime = step.time(); + playedAtCurrentTime = false; + } + + if (step.sound() == null) { + deduped.add(step); + } else if (playedAtCurrentTime) { + deduped.add(new AnimationStep(step.time(), step.slot(), step.action(), step.item(), null, step.useCache())); + } else { + deduped.add(step); + playedAtCurrentTime = true; + } + } + return deduped; + } +} \ No newline at end of file diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationStep.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationStep.java new file mode 100644 index 000000000..d65fc694e --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationStep.java @@ -0,0 +1,42 @@ +package fr.maxlego08.menu.animation.compile; + +import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.api.sound.SoundOption; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * One thing the opening animation does, at one point in time. + * + *

Steps carry an absolute time rather than a delay from the previous step, so that the + * contributions of the groups, of the per button delays and, later, of the intermediary frames can + * simply be merged and sorted.

+ * + * @param time the time at which the step happens, in milliseconds since the animation started + * @param slot the slot the step applies to + * @param action what the step does to the slot + * @param item the item to display, only used by {@link StepAction#SET} + * @param sound the sound to play, or null for a silent step + * @param useCache whether the item may be served from its build cache, the {@code use-cache} + * option of the temporary item the step comes from + */ +public record AnimationStep(long time, int slot, @NotNull StepAction action, @Nullable MenuItemStack item, + @Nullable SoundOption sound, boolean useCache) { + + /** + * A step whose item uses the build cache, as items do by default. + */ + public AnimationStep(long time, int slot, @NotNull StepAction action, @Nullable MenuItemStack item, @Nullable SoundOption sound) { + this(time, slot, action, item, sound, true); + } + + /** + * A step revealing the real item of the button owning the slot. + * + * @param time the time of the step + * @param slot the slot to reveal + */ + public AnimationStep(long time, int slot) { + this(time, slot, StepAction.REVEAL, null, null, true); + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationTiming.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationTiming.java new file mode 100644 index 000000000..8783bcb3b --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/AnimationTiming.java @@ -0,0 +1,64 @@ +package fr.maxlego08.menu.animation.compile; + +import fr.maxlego08.menu.api.animation.slot.settings.AnimationAnchor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * The boundaries of an animation, produced by the first pass of the compiler and used by the second + * one to resolve {@link AnimationAnchor}. + * + * @param groups the timing of every group that had at least one slot, keyed by group number + * @param end the time at which the last group is over, in milliseconds + */ +public record AnimationTiming(@NotNull Map groups, long end) { + + public AnimationTiming { + groups = Map.copyOf(groups); + } + + /** + * An animation with no group at all. + */ + public static final AnimationTiming EMPTY = new AnimationTiming(Map.of(), 0); + + /** + * Resolves an anchor to a time. + * + *

An anchor pointing at a group that holds no slot resolves to the end of the animation, + * which is the least surprising place to put something the author expected to see after a group + * that turned out to be empty.

+ * + * @param anchor the anchor to resolve, null resolves to the start + * @return the time in milliseconds, never negative + */ + public long resolve(@Nullable AnimationAnchor anchor) { + if (anchor == null) return 0; + + return switch (anchor.type()) { + case START -> 0; + case END -> this.end; + case MILLIS -> Math.max(0, anchor.millis()); + case GROUP_START -> { + GroupTiming timing = this.groups.get(anchor.group()); + yield timing == null ? this.end : timing.start(); + } + case GROUP_END -> { + GroupTiming timing = this.groups.get(anchor.group()); + yield timing == null ? this.end : timing.end(); + } + }; + } + + /** + * Returns whether a group holds at least one slot. + * + * @param group the group number + * @return true if the group exists in this animation + */ + public boolean hasGroup(int group) { + return this.groups.containsKey(group); + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/CompiledAnimation.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/CompiledAnimation.java new file mode 100644 index 000000000..246f48924 --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/CompiledAnimation.java @@ -0,0 +1,42 @@ +package fr.maxlego08.menu.animation.compile; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * The result of compiling the animation of an inventory: everything the engine has to play, and the + * boundaries the steps were laid out against. + * + * @param steps the steps to play, sorted by time then by slot + * @param timing the boundaries of the groups + */ +public record CompiledAnimation(@NotNull List steps, @NotNull AnimationTiming timing) { + + /** + * An animation with nothing to play. + */ + public static final CompiledAnimation EMPTY = new CompiledAnimation(List.of(), AnimationTiming.EMPTY); + + public CompiledAnimation { + steps = List.copyOf(steps); + } + + /** + * Returns whether there is nothing to play. + * + * @return true if the animation has no step + */ + public boolean isEmpty() { + return this.steps.isEmpty(); + } + + /** + * Gets the time at which the last step happens. + * + * @return the duration in milliseconds, 0 if there is no step + */ + public long duration() { + return this.steps.isEmpty() ? 0 : this.steps.getLast().time(); + } +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/GroupTiming.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/GroupTiming.java new file mode 100644 index 000000000..dc5b3a57e --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/GroupTiming.java @@ -0,0 +1,10 @@ +package fr.maxlego08.menu.animation.compile; + +/** + * When a group of the animation starts and ends. + * + * @param start the time at which the first slot of the group is revealed, in milliseconds + * @param end the time at which the group is over, in milliseconds + */ +public record GroupTiming(long start, long end) { +} diff --git a/Common/src/main/java/fr/maxlego08/menu/animation/compile/StepAction.java b/Common/src/main/java/fr/maxlego08/menu/animation/compile/StepAction.java new file mode 100644 index 000000000..6e68c78bc --- /dev/null +++ b/Common/src/main/java/fr/maxlego08/menu/animation/compile/StepAction.java @@ -0,0 +1,24 @@ +package fr.maxlego08.menu.animation.compile; + +/** + * What an {@link AnimationStep} does to its slot. + */ +public enum StepAction { + + /** + * Displays the real item built by the button owning the slot. This is what the opening + * animation does when it reaches a slot it had hidden. + */ + REVEAL, + + /** + * Displays an item that belongs to the animation itself, such as a placeholder or a temporary + * item. The slot stays owned by the animation. + */ + SET, + + /** + * Empties the slot. The slot stays owned by the animation. + */ + CLEAR +} diff --git a/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java b/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java index 40740bf89..cff4586a7 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/enums/Permission.java @@ -50,7 +50,8 @@ public enum Permission { ZMENU_BEDROCK, ZMENU_ADDONS, - ZMENU_DOCUMENTATION, ZMENU_WEBSITE; + ZMENU_DOCUMENTATION, ZMENU_WEBSITE, + ZMENU_ANIMATION_RESET; private final String permission; diff --git a/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java b/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java index aa6cefe36..dbb0d85bf 100644 --- a/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java +++ b/Common/src/main/java/fr/maxlego08/menu/common/utils/cache/YamlFileCache.java @@ -2,9 +2,11 @@ import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; +import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; +import java.io.IOException; import java.nio.file.Path; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -36,6 +38,34 @@ public static Optional getYamlFileEntry(Path path) { return Optional.of(entry); } + /** + * Like {@link #getYamlFileEntry(Path)}, but a file that is not valid YAML throws instead of + * being read as an empty configuration. A file that fails is not cached. + * + * @throws IOException if the file cannot be read + * @throws InvalidConfigurationException if the file is not valid YAML + */ + public static Optional getYamlFileEntryStrict(Path path) throws IOException, InvalidConfigurationException { + File file = path.toFile(); + + if (!file.exists()) { + cache.invalidate(path); + return Optional.empty(); + } + + YamlFileCacheEntry entry = cache.getIfPresent(path); + + if (entry != null && entry.isUpToDate()) { + return Optional.of(entry); + } + + YamlConfiguration config = new YamlConfiguration(); + config.load(file); + entry = new YamlFileCacheEntry(file, config); + cache.put(path, entry); + return Optional.of(entry); + } + public static Optional getYamlConfiguration(Path path) { return getYamlFileEntry(path).map(YamlFileCacheEntry::getYamlConfiguration); } diff --git a/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java b/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java index 9be5f56dd..cf0ec595e 100644 --- a/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java +++ b/Common/src/main/java/fr/maxlego08/menu/inventory/VInventory.java @@ -46,12 +46,28 @@ public abstract class VInventory extends ZUtils implements Cloneable, BaseInvent private ClearInvType clearInvType = ClearInvType.DEFAULT; private boolean isClose = false; + private boolean frozen = false; @Override public boolean isClose() { return this.isClose; } + @Override + public boolean isFrozen() { + return this.frozen; + } + + /** + * Freezes or unfreezes this inventory. A frozen inventory ignores every click, which is used + * while the opening animation plays. + * + * @param frozen whether the inventory must ignore clicks + */ + public void setFrozen(boolean frozen) { + this.frozen = frozen; + } + public int getId() { return this.id; } diff --git a/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java b/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java index 3056be13c..ee235fdb9 100644 --- a/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java +++ b/Common/src/main/java/fr/maxlego08/menu/inventory/inventories/InventoryDefault.java @@ -4,16 +4,37 @@ import fr.maxlego08.menu.api.Inventory; import fr.maxlego08.menu.api.InventoryManager; import fr.maxlego08.menu.api.MenuItemStack; +import fr.maxlego08.menu.animation.compile.AnimationCompiler; +import fr.maxlego08.menu.animation.compile.AnimationStep; +import fr.maxlego08.menu.animation.compile.CompiledAnimation; +import fr.maxlego08.menu.animation.compile.StepAction; import fr.maxlego08.menu.api.MenuPlugin; +import fr.maxlego08.menu.api.animation.slot.Animation; +import fr.maxlego08.menu.api.animation.slot.AnimationContext; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayMode; +import fr.maxlego08.menu.api.animation.slot.AnimationPlayback; +import fr.maxlego08.menu.api.animation.slot.AnimationSlots; +import fr.maxlego08.menu.api.animation.slot.settings.AnimationVariant; +import fr.maxlego08.menu.api.animation.slot.settings.ButtonAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationGroup; +import fr.maxlego08.menu.api.animation.slot.settings.SlotAnimationSettings; +import fr.maxlego08.menu.api.animation.slot.settings.idle.ButtonIdleAnimation; +import fr.maxlego08.menu.api.animation.slot.settings.idle.IdleFrame; import fr.maxlego08.menu.api.button.Button; +import fr.maxlego08.menu.api.configuration.Configuration; import fr.maxlego08.menu.api.engine.InventoryEngine; import fr.maxlego08.menu.api.engine.InventoryResult; import fr.maxlego08.menu.api.engine.ItemButton; +import fr.maxlego08.menu.api.engine.OpenCause; +import fr.maxlego08.menu.api.event.events.AnimationEndEvent; +import fr.maxlego08.menu.api.event.events.AnimationStartEvent; import fr.maxlego08.menu.api.exceptions.InventoryOpenException; import fr.maxlego08.menu.api.inventory.ChestInventory; import fr.maxlego08.menu.api.inventory.ContainerInventory; import fr.maxlego08.menu.api.pattern.Pattern; +import fr.maxlego08.menu.api.requirement.Action; import fr.maxlego08.menu.api.requirement.RefreshRequirement; +import fr.maxlego08.menu.api.requirement.Requirement; import fr.maxlego08.menu.api.utils.Placeholders; import fr.maxlego08.menu.inventory.VInventory; import fr.maxlego08.menu.zcore.logger.Logger; @@ -25,10 +46,14 @@ import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jspecify.annotations.NonNull; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; /** @@ -36,13 +61,25 @@ */ public class InventoryDefault extends VInventory implements InventoryEngine { - private final Map timers = new ConcurrentHashMap<>(); + private final Map refreshTimers = new ConcurrentHashMap<>(); + private final Map buttonsByAnimationSlot = new ConcurrentHashMap<>(); + private final Set slotsOwnedByAnimation = ConcurrentHashMap.newKeySet(); + private final Map idleTasksBySlot = new ConcurrentHashMap<>(); + private final Map idleCycleBySlot = new ConcurrentHashMap<>(); + private final Map idleCyclesAwaitingRebuild = new ConcurrentHashMap<>(); + private volatile boolean useSharedIdleTimer; + private final Set sharedIdleCycles = ConcurrentHashMap.newKeySet(); + private final Object sharedIdleLock = new Object(); + private WrappedTask sharedIdleTimer; + + private volatile RunningAnimation runningAnimation; + private volatile Placeholders openPlaceholders = new Placeholders(); private PerformanceDebug perfDebug; private Inventory inventory; private List oldInventories = new ArrayList<>(); private List