From 7dc5d5c3b8536fea289b4ac71e124fb02a091926 Mon Sep 17 00:00:00 2001 From: Essential CI Date: Fri, 3 May 2024 10:07:29 +0000 Subject: [PATCH] Version 1.3.2 --- .gitattributes | 6 + .github/workflows/main.yml | 86 ++ .gitignore | 35 + .gitmodules | 3 + .init | 0 LICENSE | 31 + README.md | 192 +++ api/api/api.api | 729 +++++++++++ api/build.gradle.kts | 82 ++ api/gradle.properties | 1 + api/root.gradle.kts | 25 + .../gg/essential/api/utils/JsonHolder.java | 270 ++++ api/src/main/kotlin/gg/essential/api/DI.kt | 38 + .../kotlin/gg/essential/api/EssentialAPI.kt | 137 ++ .../essential/api/commands/ArgumentParser.kt | 38 + .../essential/api/commands/ArgumentQueue.kt | 32 + .../gg/essential/api/commands/Command.kt | 129 ++ .../essential/api/commands/CommandRegistry.kt | 36 + .../gg/essential/api/commands/annotations.kt | 88 ++ .../essential/api/config/EssentialConfig.kt | 206 +++ .../essential/api/cosmetics/RenderCosmetic.kt | 18 + .../gg/essential/api/data/OnboardingData.kt | 27 + .../gg/essential/api/gui/EssentialGUI.kt | 199 +++ .../gg/essential/api/gui/GuiRequiresTOS.kt | 19 + .../gg/essential/api/gui/Notifications.kt | 224 ++++ .../api/gui/essentialComponentFactory.kt | 195 +++ .../api/profile/WrappedGameProfile.kt | 65 + .../kotlin/gg/essential/api/utils/GuiUtil.kt | 49 + .../gg/essential/api/utils/KotlinAdapter.kt | 47 + .../gg/essential/api/utils/MinecraftUtils.kt | 72 ++ .../gg/essential/api/utils/Multithreading.kt | 55 + .../gg/essential/api/utils/SerialExecutor.kt | 43 + .../essential/api/utils/ShutdownHookUtil.kt | 31 + .../essential/api/utils/TrustedHostsUtil.kt | 50 + .../kotlin/gg/essential/api/utils/WebUtil.kt | 94 ++ .../main/kotlin/gg/essential/api/utils/di.kt | 49 + .../essential/api/utils/mojang/MojangAPI.kt | 70 + .../gg/essential/api/utils/mojang/data.kt | 104 ++ api/src/main/resources/fabric.mod.json | 13 + build-logic/build.gradle.kts | 57 + build-logic/settings.gradle.kts | 11 + .../essential/SyncToExternalRepoTask.kt | 148 +++ .../src/main/kotlin/essential/dokka.kt | 30 + .../essential/embedded-loader.gradle.kts | 101 ++ .../kotlin/essential/pinned-jar.gradle.kts | 134 ++ .../src/main/kotlin/essential/preprocessor.kt | 70 + .../src/main/kotlin/essential/repos.kt | 35 + .../src/main/kotlin/essential/universal.kt | 82 ++ .../main/kotlin/essential/utils.gradle.kts | 14 + .../gg/essential/gradle/BundlePlugin.kt | 222 ++++ .../kotlin/gg/essential/gradle/MixinPlugin.kt | 66 + .../gg/essential/gradle/RelocatePlugin.kt | 195 +++ .../gradle/compatmixin/CompatMixinTask.kt | 187 +++ .../gg/essential/gradle/util/KotlinVersion.kt | 49 + .../RelaxFabricLoaderDependencyTransform.kt | 50 + .../util/SlimKotlinForForgeTransform.kt | 57 + .../util/StripKotlinMetadataTransform.kt | 97 ++ build.gradle.kts | 231 ++++ changelog/release-1.0.0.1.md | 16 + changelog/release-1.0.0.10.1.md | 4 + changelog/release-1.0.0.10.md | 8 + changelog/release-1.0.0.2.md | 6 + changelog/release-1.0.0.3.md | 17 + changelog/release-1.0.0.4.md | 5 + changelog/release-1.0.0.5.md | 5 + changelog/release-1.0.0.6.md | 7 + changelog/release-1.0.0.8.md | 15 + changelog/release-1.0.0.9.md | 8 + changelog/release-1.0.0.md | 40 + changelog/release-1.1.0.1.md | 6 + changelog/release-1.1.0.10.md | 6 + changelog/release-1.1.0.2.md | 6 + changelog/release-1.1.0.3.md | 8 + changelog/release-1.1.0.4.md | 5 + changelog/release-1.1.0.5.md | 10 + changelog/release-1.1.0.6.md | 5 + changelog/release-1.1.0.7.md | 5 + changelog/release-1.1.0.9.md | 12 + changelog/release-1.1.0.md | 27 + changelog/release-1.1.1.1.md | 5 + changelog/release-1.1.1.2.md | 11 + changelog/release-1.1.1.3.md | 5 + changelog/release-1.1.1.md | 29 + changelog/release-1.2.0.1.md | 5 + changelog/release-1.2.0.10.md | 25 + changelog/release-1.2.0.11.md | 22 + changelog/release-1.2.0.12.md | 12 + changelog/release-1.2.0.13.md | 5 + changelog/release-1.2.0.14.md | 11 + changelog/release-1.2.0.3.md | 6 + changelog/release-1.2.0.4.md | 6 + changelog/release-1.2.0.5.md | 5 + changelog/release-1.2.0.6.md | 12 + changelog/release-1.2.0.7.md | 20 + changelog/release-1.2.0.8.md | 10 + changelog/release-1.2.0.9.md | 12 + changelog/release-1.2.1.1.md | 5 + changelog/release-1.2.1.2.md | 16 + changelog/release-1.2.1.3.md | 10 + changelog/release-1.2.1.4.md | 6 + changelog/release-1.2.1.5.md | 5 + changelog/release-1.2.1.6.md | 13 + changelog/release-1.2.1.7.md | 41 + changelog/release-1.2.1.8.md | 5 + changelog/release-1.2.1.md | 19 + changelog/release-1.2.2.1.md | 11 + changelog/release-1.2.2.2.md | 10 + changelog/release-1.2.2.3.md | 24 + changelog/release-1.2.2.4.md | 6 + changelog/release-1.2.2.md | 22 + changelog/release-1.2.3.1.md | 9 + changelog/release-1.2.3.md | 33 + changelog/release-1.3.0.1.md | 5 + changelog/release-1.3.0.2.md | 8 + changelog/release-1.3.0.3.md | 21 + changelog/release-1.3.0.4.md | 6 + changelog/release-1.3.0.5.md | 5 + changelog/release-1.3.0.6.md | 8 + changelog/release-1.3.0.md | 46 + changelog/release-1.3.1.1.md | 15 + changelog/release-1.3.1.2.md | 8 + changelog/release-1.3.1.3.md | 8 + changelog/release-1.3.1.md | 26 + changelog/release-1.3.2.md | 23 + clipboard/build.gradle.kts | 49 + .../gg/essential/clipboard/AWTClipboard.kt | 77 ++ .../gg/essential/clipboard/Clipboard.kt | 30 + .../gg/essential/clipboard/MacOSClipboard.kt | 41 + cosmetics/build.gradle.kts | 44 + .../gg/essential/cosmetics/CosmeticsState.kt | 266 ++++ .../essential/cosmetics/EquippedCosmetic.kt | 33 + .../essential/cosmetics/WearablesManager.kt | 112 ++ .../cosmetics/boxmask/ModelClipper.kt | 19 + .../cosmetics/boxmask/ModelClipperImpl.kt | 130 ++ .../cosmetics/events/AnimationEvent.kt | 38 + .../cosmetics/events/AnimationEventType.kt | 34 + .../cosmetics/events/AnimationTarget.kt | 18 + .../cosmetics/skinmask/SkinMaskConfig.kt | 18 + .../cosmetics/skinmask/SkinPartSetting.kt | 22 + .../state/EssentialAnimationSystem.kt | 142 ++ .../cosmetics/state/TextureAnimationSync.kt | 72 ++ .../cosmetics/state/WearableLocator.kt | 22 + .../kotlin/gg/essential/cosmetics/types.kt | 21 + .../kotlin/gg/essential/mod/EssentialAsset.kt | 25 + .../kotlin/gg/essential/mod/Model.kt | 45 + .../kotlin/gg/essential/mod/Skin.kt | 38 + .../gg/essential/mod/asset/AssetProvider.kt | 20 + .../gg/essential/mod/cosmetics/CapeModel.kt | 88 ++ .../essential/mod/cosmetics/CosmeticAssets.kt | 46 + .../essential/mod/cosmetics/CosmeticBundle.kt | 42 + .../mod/cosmetics/CosmeticCategory.kt | 51 + .../essential/mod/cosmetics/CosmeticOutfit.kt | 27 + .../essential/mod/cosmetics/CosmeticSlot.kt | 67 + .../essential/mod/cosmetics/CosmeticTier.kt | 23 + .../essential/mod/cosmetics/CosmeticType.kt | 22 + .../mod/cosmetics/CosmeticsSubject.kt | 29 + .../essential/mod/cosmetics/EmoteWheelPage.kt | 21 + .../gg/essential/mod/cosmetics/OutfitSkin.kt | 48 + .../gg/essential/mod/cosmetics/PlayerModel.kt | 333 +++++ .../gg/essential/mod/cosmetics/SkinLayer.kt | 22 + .../essential/mod/cosmetics/capeDisabled.kt | 37 + .../cosmetics/database/CosmeticsDatabase.kt | 42 + .../database/GitRepoCosmeticsDatabase.kt | 1139 +++++++++++++++++ .../mod/cosmetics/featured/FeaturedItem.kt | 73 ++ .../cosmetics/featured/FeaturedItemType.kt | 23 + .../mod/cosmetics/featured/FeaturedPage.kt | 19 + .../featured/FeaturedPageCollection.kt | 67 + .../cosmetics/preview/PerspectiveCamera.kt | 73 ++ .../cosmetics/settings/CosmeticProperty.kt | 354 +++++ .../settings/CosmeticPropertyType.kt | 31 + .../mod/cosmetics/settings/CosmeticSetting.kt | 135 ++ .../cosmetics/settings/CosmeticSettingType.kt | 24 + .../cosmetics/settings/CosmeticSettings.kt | 25 + .../settings/FallbackPolymorphicSerializer.kt | 63 + .../settings/UntypedCosmeticSetting.kt | 34 + .../kotlin/gg/essential/model/Animation.kt | 410 ++++++ .../kotlin/gg/essential/model/BedrockModel.kt | 367 ++++++ .../kotlin/gg/essential/model/Bone.kt | 230 ++++ .../kotlin/gg/essential/model/Box3.kt | 165 +++ .../kotlin/gg/essential/model/Cube.kt | 180 +++ .../kotlin/gg/essential/model/CubeUvData.kt | 21 + .../kotlin/gg/essential/model/EnumPart.kt | 69 + .../kotlin/gg/essential/model/Face.kt | 86 ++ .../gg/essential/model/ModelInstance.kt | 114 ++ .../kotlin/gg/essential/model/ModelParser.kt | 147 +++ .../gg/essential/model/ParticleEffect.kt | 29 + .../gg/essential/model/ParticleSystem.kt | 1094 ++++++++++++++++ .../gg/essential/model/PositionTexVertex.kt | 40 + .../gg/essential/model/RenderMetadata.kt | 26 + .../kotlin/gg/essential/model/Side.kt | 54 + .../gg/essential/model/SoundCategory.kt | 35 + .../kotlin/gg/essential/model/SoundEffect.kt | 41 + .../kotlin/gg/essential/model/Vector3.kt | 297 +++++ .../gg/essential/model/backend/PlayerPose.kt | 110 ++ .../essential/model/backend/RenderBackend.kt | 27 + .../model/collision/CollisionProvider.kt | 27 + .../model/collision/PlaneCollisionProvider.kt | 60 + .../gg/essential/model/file/AnimationFile.kt | 152 +++ .../gg/essential/model/file/ModelFile.kt | 127 ++ .../gg/essential/model/file/ParticlesFile.kt | 755 +++++++++++ .../model/file/SoundDefinitionsFile.kt | 71 + .../kotlin/gg/essential/model/light/Light.kt | 34 + .../gg/essential/model/light/LightProvider.kt | 25 + .../essential/model/molang/MolangContext.kt | 103 ++ .../model/molang/MolangExpression.kt | 448 +++++++ .../gg/essential/model/molang/MolangVec3.kt | 63 + .../gg/essential/model/util/AnySerializer.kt | 61 + .../model/util/ColorAsRgbSerializer.kt | 28 + .../kotlin/gg/essential/model/util/Instant.kt | 22 + .../model/util/InstantAsIso8601Serializer.kt | 28 + .../model/util/InstantAsMillisSerializer.kt | 28 + .../model/util/ListOrSingleSerializer.kt | 36 + .../model/util/PairAsListSerializer.kt | 40 + .../essential/model/util/PlayerPoseManager.kt | 223 ++++ .../kotlin/gg/essential/model/util/TreeMap.kt | 74 ++ .../gg/essential/model/util/UMatrixStack.kt | 142 ++ .../essential/model/util/UVertexConsumer.kt | 28 + .../kotlin/gg/essential/model/util/kotgl.kt | 124 ++ .../gg/essential/model/util/platformMath.kt | 18 + .../gg/essential/model/util/quaternion.kt | 171 +++ .../essential/network/cosmetics/Cosmetic.kt | 169 +++ .../SnakeAsUpperCaseSerializer.kt | 33 + .../kotlin/gg/essential/model/util/Instant.kt | 24 + .../gg/essential/model/util/platformMath.kt | 24 + elementa/build.gradle.kts | 37 + elementa/layoutdsl/build.gradle.kts | 39 + .../essential/gui/common/HollowUIContainer.kt | 27 + .../gg/essential/gui/common/ListState.kt | 200 +++ .../kotlin/gg/essential/gui/common/Spacer.kt | 38 + .../common/constraints/AlternateConstraint.kt | 77 ++ .../constraints/CenterPixelConstraint.kt | 53 + .../FillConstraintIncludingPadding.kt | 112 ++ .../SpacedCramSiblingConstraint.kt | 155 +++ .../essential/gui/common/stateExtensions.kt | 23 + .../gg/essential/gui/layoutdsl/README.md | 1098 ++++++++++++++++ .../gg/essential/gui/layoutdsl/alignment.kt | 66 + .../gg/essential/gui/layoutdsl/arrangement.kt | 281 ++++ .../kotlin/gg/essential/gui/layoutdsl/axis.kt | 17 + .../essential/gui/layoutdsl/basicModifiers.kt | 73 ++ .../gg/essential/gui/layoutdsl/color.kt | 72 ++ .../gg/essential/gui/layoutdsl/containers.kt | 258 ++++ .../gg/essential/gui/layoutdsl/events.kt | 71 + .../gg/essential/gui/layoutdsl/float.kt | 18 + .../gg/essential/gui/layoutdsl/layout.kt | 370 ++++++ .../kotlin/gg/essential/gui/layoutdsl/lazy.kt | 46 + .../gg/essential/gui/layoutdsl/modifier.kt | 43 + .../kotlin/gg/essential/gui/layoutdsl/size.kt | 160 +++ .../gg/essential/gui/layoutdsl/state.kt | 60 + .../kotlin/gg/essential/gui/layoutdsl/util.kt | 58 + .../essential/gui/util/elementaExtensions.kt | 447 +++++++ elementa/statev2/build.gradle.kts | 39 + .../v2/collections/MutableTrackedList.kt | 228 ++++ .../state/v2/collections/MutableTrackedSet.kt | 225 ++++ .../state/v2/collections/TrackedList.kt | 100 ++ .../state/v2/collections/TrackedSet.kt | 67 + .../elementa/state/v2/collections/utils.kt | 42 + .../gui/elementa/state/v2/color/color.kt | 22 + .../elementa/state/v2/combinators/booleans.kt | 25 + .../gui/elementa/state/v2/combinators/pair.kt | 17 + .../elementa/state/v2/combinators/state.kt | 38 + .../elementa/state/v2/combinators/strings.kt | 21 + .../elementa/state/v2/combinators/utils.kt | 17 + .../gui/elementa/state/v2/compatibility.kt | 106 ++ .../gui/elementa/state/v2/coroutine.kt | 48 + .../gui/elementa/state/v2/flatten.kt | 14 + .../gui/elementa/state/v2/impl/Impl.kt | 56 + .../gui/elementa/state/v2/impl/basic/impl.kt | 318 +++++ .../gui/elementa/state/v2/impl/legacy/impl.kt | 255 ++++ .../elementa/state/v2/impl/minimal/impl.kt | 311 +++++ .../essential/gui/elementa/state/v2/list.kt | 66 + .../gui/elementa/state/v2/listCombinators.kt | 150 +++ .../gg/essential/gui/elementa/state/v2/set.kt | 58 + .../gui/elementa/state/v2/setCombinators.kt | 92 ++ .../essential/gui/elementa/state/v2/state.kt | 316 +++++ .../gui/elementa/state/v2/stateBy.kt | 34 + feature-flags/build.gradle.kts | 29 + .../config/AccessedViaReflection.java | 48 + .../gg/essential/config/FeatureFlags.java | 49 + .../gg/essential/config/LoadsResources.java | 77 ++ .../gg/essential/config/HideIfDisabled.kt | 44 + features.properties | 3 + gradle.properties | 10 + gradle/libs.versions.toml | 5 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 190 +++ gradlew.bat | 89 ++ gui/elementa/build.gradle.kts | 39 + .../gg/essential/gui/common/WeakState.kt | 77 ++ .../gg/essential/gui/common/extensions.kt | 253 ++++ .../gui/elementa/GuiScaleOffsetConstraint.kt | 42 + .../essential/gui/elementa/LazyConstraint.kt | 63 + .../elementa/PredicatedHitTestContainer.kt | 37 + .../gui/elementa/effects/AlphaEffect.kt | 199 +++ .../gui/elementa/effects/GradientEffect.kt | 109 ++ .../gui/elementa/effects/ZIndexEffect.kt | 55 + .../elementa/essentialmarkdown/DrawState.kt | 43 + .../essentialmarkdown/EssentialMarkdown.kt | 286 +++++ .../essentialmarkdown/MarkdownConfig.kt | 122 ++ .../essentialmarkdown/MarkdownRenderer.kt | 281 ++++ .../drawables/BlockquoteDrawable.kt | 94 ++ .../essentialmarkdown/drawables/Drawable.kt | 212 +++ .../drawables/DrawableList.kt | 141 ++ .../drawables/HardBreakDrawable.kt | 47 + .../drawables/HeaderDrawable.kt | 95 ++ .../drawables/ImageDrawable.kt | 107 ++ .../drawables/ListDrawable.kt | 216 ++++ .../drawables/ParagraphDrawable.kt | 458 +++++++ .../drawables/SoftBreakDrawable.kt | 46 + .../drawables/TextDrawable.kt | 455 +++++++ .../ext/colorattribute/ColorAttribute.kt | 21 + .../colorattribute/ColorAttributeExtension.kt | 26 + .../ColorAttributeDelimiterProcessor.kt | 117 ++ .../essentialmarkdown/selection/Cursor.kt | 45 + .../selection/ImageCursor.kt | 26 + .../essentialmarkdown/selection/Selection.kt | 129 ++ .../essentialmarkdown/selection/TextCursor.kt | 40 + .../elementa/transitions/FadeInTransition.kt | 52 + .../gg/essential/gui/layoutdsl/gradient.kt | 43 + .../gg/essential/util/guiElementaPlatform.kt | 23 + .../main/kotlin/gg/essential/util/strings.kt | 21 + gui/essential/build.gradle.kts | 36 + .../java/gg/essential/handlers/CertChain.java | 88 ++ .../java/gg/essential/util/APIException.java | 24 + .../util/PlayerNotFoundException.java | 18 + .../gg/essential/util/RateLimitException.java | 18 + .../gg/essential/util/UuidNameLookup.java | 185 +++ .../gg/essential/gui/EssentialPalette.kt | 966 ++++++++++++++ .../gg/essential/gui/common/AutoImageSize.kt | 88 ++ .../gg/essential/gui/common/Checkbox.kt | 96 ++ .../gui/common/CompactEssentialToggle.kt | 151 +++ .../essential/gui/common/ContextOptionMenu.kt | 245 ++++ .../common/EssentialCollapsibleSearchbar.kt | 190 +++ .../essential/gui/common/EssentialDropDown.kt | 248 ++++ .../essential/gui/common/EssentialSlider.kt | 159 +++ .../gg/essential/gui/common/FadeEffect.kt | 45 + .../essential/gui/common/HighlightedBlock.kt | 157 +++ .../gui/common/HoverableInfoBlock.kt | 55 + .../gg/essential/gui/common/IconButton.kt | 227 ++++ .../essential/gui/common/ImageLoadCallback.kt | 25 + .../gg/essential/gui/common/LoadingIcon.kt | 73 ++ .../gg/essential/gui/common/MenuButton.kt | 768 +++++++++++ .../gui/common/OldEssentialDropDown.kt | 229 ++++ .../gg/essential/gui/common/ScrollSpacer.kt | 27 + .../gui/common/SequenceAnimatedUIImage.kt | 65 + .../gg/essential/gui/common/Tooltips.kt | 322 +++++ .../constraints/ColumnPositionConstraint.kt | 49 + .../common/constraints/DivisionConstraint.kt | 55 + .../MarkdownContentWidthConstraint.kt | 54 + .../constraints/MultiplicativeConstraint.kt | 69 + .../common/effect/AdvancedScissorEffect.kt | 74 ++ .../gui/common/input/AbstractTextInput.kt | 1122 ++++++++++++++++ .../gui/common/input/StateTextInput.kt | 119 ++ .../gui/common/input/UIMultilineTextInput.kt | 202 +++ .../essential/gui/common/input/UITextInput.kt | 173 +++ .../gui/common/input/essentialInput.kt | 306 +++++ .../gui/common/modal/CancelableInputModal.kt | 183 +++ .../gui/common/modal/ConfirmDenyModal.kt | 105 ++ .../modal/DangerConfirmationEssentialModal.kt | 31 + .../gui/common/modal/EssentialModal.kt | 312 +++++ .../gg/essential/gui/common/modal/Modal.kt | 105 ++ .../gui/common/modal/NoticeEssentialModal.kt | 24 + .../gui/common/modal/PropertiesModal.kt | 60 + .../modal/SearchableConfirmDenyModal.kt | 100 ++ .../gui/common/modal/UsernameInputModal.kt | 118 ++ .../common/modal/VerticalConfirmDenyModal.kt | 37 + .../common/outline/GuiScaleOffsetOutline.kt | 44 + .../gui/common/shadow/EssentialText.kt | 205 +++ .../gui/common/shadow/ShadowEffect.kt | 109 ++ .../essential/gui/common/shadow/ShadowIcon.kt | 100 ++ .../gg/essential/gui/image/ImageFactory.kt | 140 ++ .../gui/image/ResourceImageFactory.kt | 44 + .../gg/essential/gui/layoutdsl/button.kt | 112 ++ .../gg/essential/gui/layoutdsl/checkbox.kt | 31 + .../gg/essential/gui/layoutdsl/effects.kt | 90 ++ .../kotlin/gg/essential/gui/layoutdsl/icon.kt | 31 + .../kotlin/gg/essential/gui/layoutdsl/text.kt | 143 +++ .../gg/essential/gui/layoutdsl/tooltips.kt | 107 ++ .../gg/essential/gui/layoutdsl/wrappedText.kt | 106 ++ .../essential/util/ButtonTextureProvider.kt | 112 ++ .../gg/essential/util/CachedAvatarImage.kt | 191 +++ .../kotlin/gg/essential/util/HttpUtils.kt | 35 + .../kotlin/gg/essential/util/ServerType.kt | 37 + .../main/kotlin/gg/essential/util/USession.kt | 29 + .../kotlin/gg/essential/util/dispatchers.kt | 23 + .../essential/util/essentialGuiExtensions.kt | 435 +++++++ .../gg/essential/util/guiEssentialPlatform.kt | 60 + .../gg/essential/util/image/bitmap/Bitmap.kt | 78 ++ .../essential/util/image/bitmap/extensions.kt | 68 + .../util/image/bitmap/impl/IntArrayBitmap.kt | 61 + gui/vigilance/build.gradle.kts | 32 + .../builder/StateBackedPropertyValue.kt | 28 + .../builder/VisibleDependencyPredicate.kt | 24 + .../gui/vigilancev2/components/settings.kt | 146 +++ .../gui/vigilancev2/components/subcategory.kt | 54 + .../gg/essential/gui/vigilancev2/data.kt | 38 + .../gg/essential/gui/vigilancev2/layout.kt | 17 + .../vigilancev2/palette/VigilancePalette.kt | 34 + .../gui/vigilancev2/utils/propertyData.kt | 19 + infra/build.gradle.kts | 25 + .../toolbox/chat/enums/ChannelType.java | 50 + .../toolbox/chat/enums/ReportVerdict.java | 35 + .../toolbox/chat/model/Channel.java | 125 ++ .../toolbox/chat/model/ChannelSettings.java | 25 + .../toolbox/chat/model/ClosedInfo.java | 55 + .../toolbox/chat/model/CreatedInfo.java | 46 + .../toolbox/chat/model/Message.java | 112 ++ .../toolbox/chat/model/MessageReport.java | 91 ++ .../toolbox/chat/model/ReportVerdict.java | 59 + .../enums/FriendRequestPrivacySetting.java | 20 + .../enums/RelationshipState.java | 20 + .../relationships/enums/RelationshipType.java | 31 + ...riendRequestPrivacySettingTypeAdapter.java | 47 + .../RelationshipStateAdapter.java | 47 + .../RelationshipTypeAdapter.java | 47 + .../serialization/DateTimeTypeAdapter.java | 43 + .../serialization/UUIDTypeAdapter.java | 47 + .../sparkuniverse/toolbox/util/DateTime.java | 167 +++ .../sparkuniverse/toolbox/util/Validate.java | 47 + .../gg/essential/coins/model/CoinBundle.java | 74 ++ .../common/enums/ActivityType.java | 18 + .../common/enums/ProfileStatus.java | 19 + .../model/relationships/Relationship.java | 95 ++ .../common/packet/Packet.java | 42 + .../chat/ChatChannelMemberAddPacket.java | 43 + .../chat/ChatChannelMemberRemovePacket.java | 43 + .../chat/ChatChannelMessageDeletePacket.java | 38 + .../packet/chat/ChatChannelUpdatePacket.java | 51 + .../chat/ClientChatChannelCreatePacket.java | 59 + .../ClientChatChannelMessageCreatePacket.java | 65 + ...ientChatChannelMessageReadStatePacket.java | 46 + .../ClientChatChannelMessageReportPacket.java | 51 + .../ClientChatChannelMessageUpdatePacket.java | 50 + ...ientChatChannelMessagesRetrievePacket.java | 54 + .../chat/ClientChatChannelMutePacket.java | 34 + ...rverChannelMemberActionResponsePacket.java | 36 + .../chat/ServerChatChannelAddPacket.java | 36 + .../chat/ServerChatChannelClearPacket.java | 20 + .../chat/ServerChatChannelMessagePacket.java | 38 + .../ServerChatChannelMessageReportPacket.java | 27 + ...ChatChannelMessageReportReasonsPacket.java | 35 + .../chat/ServerChatChannelRemovePacket.java | 34 + .../ClientCheckoutClaimCoinsPacket.java | 27 + .../ClientCheckoutCoinBundlePacket.java | 45 + .../ClientCheckoutCosmeticsPacket.java | 45 + ...ClientCheckoutDynamicCoinBundlePacket.java | 47 + ...tCheckoutPartnerCodeRequestDataPacket.java | 30 + .../ClientCheckoutStoreBundlePacket.java | 28 + ...erverCheckoutClaimCoinsResponsePacket.java | 30 + .../ServerCheckoutPartnerCodeDataPacket.java | 46 + .../checkout/ServerCheckoutUrlPacket.java | 30 + .../coins/ClientCoinBundleOptionsPacket.java | 33 + .../coins/ClientCoinsBalancePacket.java | 16 + .../coins/ServerCoinBundleOptionsPacket.java | 32 + .../coins/ServerCoinsBalancePacket.java | 46 + .../ClientConnectionDisconnectPacket.java | 59 + .../connection/ConnectionKeepAlivePacket.java | 20 + .../ConnectionRegisterPacketTypeIdPacket.java | 41 + .../ServerConnectionReconnectPacket.java | 22 + .../ClientCosmeticAnimationTriggerPacket.java | 44 + ...tCosmeticBulkRequestUnlockStatePacket.java | 40 + .../ClientCosmeticCheckoutPacket.java | 35 + .../cosmetic/ClientCosmeticRequestPacket.java | 42 + ...icsUserEquippedVisibilityTogglePacket.java | 31 + .../ServerCosmeticAnimationTriggerPacket.java | 58 + .../ServerCosmeticPlayerSettingsPacket.java | 52 + .../ServerCosmeticTypesPopulatePacket.java | 51 + .../ServerCosmeticsPopulatePacket.java | 51 + .../ServerCosmeticsRevokePurchasePacket.java | 38 + .../ServerCosmeticsSkinTexturePacket.java | 46 + .../ServerCosmeticsUserEquippedPacket.java | 50 + ...CosmeticsUserEquippedVisibilityPacket.java | 34 + .../ServerCosmeticsUserUnlockedPacket.java | 65 + .../ClientCosmeticCapesUnlockedPacket.java | 33 + ...ClientCosmeticCategoriesRequestPacket.java | 55 + ...erverCosmeticCategoriesPopulatePacket.java | 34 + .../ClientCosmeticEmoteWheelSelectPacket.java | 38 + .../ClientCosmeticEmoteWheelUpdatePacket.java | 61 + ...erverCosmeticEmoteWheelPopulatePacket.java | 41 + ...ticOutfitCosmeticSettingsUpdatePacket.java | 59 + .../ClientCosmeticOutfitCreatePacket.java | 69 + .../ClientCosmeticOutfitDeletePacket.java | 33 + ...icOutfitEquippedCosmeticsUpdatePacket.java | 57 + .../ClientCosmeticOutfitNameUpdatePacket.java | 42 + .../ClientCosmeticOutfitSelectPacket.java | 33 + ...ntCosmeticOutfitSelectedRequestPacket.java | 35 + .../ClientCosmeticOutfitSkinUpdatePacket.java | 53 + ...smeticOutfitUpdateFavoriteStatePacket.java | 40 + .../ServerCosmeticOutfitPopulatePacket.java | 34 + ...rCosmeticOutfitSelectedResponsePacket.java | 69 + .../currency/ClientCurrencyOptionsPacket.java | 16 + .../currency/ServerCurrencyOptionsPacket.java | 32 + .../common/packet/ice/IceCandidatePacket.java | 46 + .../common/packet/ice/IceSessionPacket.java | 55 + .../packet/media/ClientMediaCreatePacket.java | 62 + .../media/ClientMediaDeleteRequestPacket.java | 31 + .../media/ClientMediaGetUploadUrlPacket.java | 17 + .../media/ClientMediaRequestPacket.java | 29 + .../packet/media/ClientMediaUpdatePacket.java | 61 + .../media/ServerMediaPopulatePacket.java | 34 + .../media/ServerMediaUploadUrlPacket.java | 42 + .../packet/mod/ClientModsAnnouncePacket.java | 120 ++ .../ServerMultiplayerJoinServerPacket.java | 33 + .../notices/ClientNoticeDismissPacket.java | 31 + .../notices/ClientNoticeRequestPacket.java | 78 ++ .../notices/ServerNoticePopulatePacket.java | 34 + .../notices/ServerNoticeRemovePacket.java | 39 + .../pingproxy/ClientPingProxyPacket.java | 49 + .../ServerPingProxyResponsePacket.java | 53 + .../profile/ClientProfileActivityPacket.java | 44 + .../profile/ServerProfileActivityPacket.java | 59 + .../profile/ServerProfileStatusPacket.java | 58 + ...ClientProfileTrustedHostsCreatePacket.java | 45 + ...ClientProfileTrustedHostsDeletePacket.java | 33 + .../ServerProfileTrustedHostsClearPacket.java | 20 + ...rverProfileTrustedHostsPopulatePacket.java | 38 + ...ServerProfileTrustedHostsRemovePacket.java | 35 + .../ClientRelationshipCreatePacket.java | 54 + .../RelationshipDeletePacket.java | 48 + ...elationshipCreateFailedResponsePacket.java | 33 + .../ServerRelationshipDeletePacket.java | 34 + .../ServerRelationshipPopulatePacket.java | 46 + .../ServerUuidNameMapPacket.java | 41 + .../FriendRequestPrivacySettingPacket.java | 62 + .../packet/response/ResponseActionPacket.java | 46 + ...tServerDiscoveryRequestPopulatePacket.java | 30 + .../ServerServerDiscoveryPopulatePacket.java | 36 + .../packet/skin/ClientSkinCreatePacket.java | 50 + .../packet/skin/ClientSkinDeletePacket.java | 28 + .../skin/ClientSkinUpdateDataPacket.java | 43 + .../ClientSkinUpdateFavoriteStatePacket.java | 32 + .../ClientSkinUpdateLastUsedStatePacket.java | 29 + .../skin/ClientSkinUpdateNamePacket.java | 35 + .../packet/skin/ServerSkinPopulatePacket.java | 31 + ...iscordRequestJoinServerResponsePacket.java | 33 + .../SocialDiscordRequestJoinServerPacket.java | 45 + .../SocialInviteToServerCancelPacket.java | 35 + .../social/SocialInviteToServerPacket.java | 45 + .../SubscriptionUpdatePacket.java | 80 ++ .../telemetry/ClientTelemetryPacket.java | 47 + .../upnp/ClientUPnPSessionClosePacket.java | 20 + .../upnp/ClientUPnPSessionCreatePacket.java | 67 + .../ClientUPnPSessionInvitesAddPacket.java | 31 + .../ClientUPnPSessionInvitesRemovePacket.java | 31 + ...lientUPnPSessionPingProxyUpdatePacket.java | 33 + .../upnp/ClientUPnPSessionUpdatePacket.java | 56 + .../ServerUPnPSessionInviteAddPacket.java | 35 + .../upnp/ServerUPnPSessionPopulatePacket.java | 41 + .../upnp/ServerUPnPSessionRemovePacket.java | 41 + .../ClientWardrobeSettingsPacket.java | 18 + ...lientWardrobeStoreBundleRequestPacket.java | 29 + .../ServerWardrobeSettingsPacket.java | 72 ++ .../ServerWardrobeStoreBundlePacket.java | 34 + .../common/util/LoginUtil.java | 60 + .../gg/essential/cosmetics/CosmeticSlot.java | 69 + .../gg/essential/cosmetics/SkinLayer.java | 24 + .../cosmetics/holder/PriceHolder.java | 33 + .../cosmetics/holder/SkinLayersHolder.java | 38 + .../essential/cosmetics/model/Cosmetic.java | 229 ++++ .../cosmetics/model/CosmeticAssets.java | 79 ++ .../cosmetics/model/CosmeticCategory.java | 140 ++ .../cosmetics/model/CosmeticGeometry.java | 43 + .../cosmetics/model/CosmeticOutfit.java | 132 ++ .../cosmetics/model/CosmeticSetting.java | 78 ++ .../cosmetics/model/CosmeticSkinMask.java | 39 + .../cosmetics/model/CosmeticStoreBundle.java | 82 ++ .../model/CosmeticStoreBundleSkin.java | 43 + .../cosmetics/model/CosmeticTier.java | 21 + .../cosmetics/model/CosmeticType.java | 68 + .../cosmetics/model/CosmeticUnlockData.java | 41 + .../essential/cosmetics/model/EmoteWheel.java | 82 ++ .../essential/holder/DisplayNameHolder.java | 34 + .../java/gg/essential/media/model/Media.java | 86 ++ .../media/model/MediaLocationMetadata.java | 62 + .../media/model/MediaLocationType.java | 23 + .../essential/media/model/MediaMetadata.java | 90 ++ .../essential/media/model/MediaVariant.java | 30 + .../gg/essential/model/EssentialAsset.java | 42 + .../java/gg/essential/notices/NoticeType.java | 24 + .../gg/essential/notices/model/Notice.java | 94 ++ .../essential/profiles/model/TrustedHost.java | 69 + .../model/ServerDiscovery.java | 92 ++ .../model/ServerDiscoveryAssets.java | 38 + .../java/gg/essential/skins/SkinModel.java | 19 + .../java/gg/essential/skins/model/Skin.java | 76 ++ .../java/gg/essential/upnp/UPnPPrivacy.java | 19 + .../gg/essential/upnp/model/UPnPSession.java | 118 ++ kdiscordipc/build.gradle.kts | 48 + .../util/kdiscordipc/IsolatedClassLoader.java | 190 +++ .../util/kdiscordipc/RelaunchClassLoader.java | 78 ++ .../util/kdiscordipc/KDiscordIPCLoader.kt | 58 + libs/build.gradle.kts | 39 + loader | 1 + lwjgl3/README.md | 16 + lwjgl3/build.gradle.kts | 30 + lwjgl3/impl/build.gradle.kts | 56 + .../essential/util/lwjgl3/impl/Bootstrap.kt | 44 + .../gg/essential/util/lwjgl3/impl/GLBridge.kt | 51 + .../util/lwjgl3/impl/NativeImageReaderImpl.kt | 156 +++ .../essential/util/lwjgl3/impl/TinyFdImpl.kt | 48 + .../util/lwjgl3/impl/nanovg/NanoVGImpl.kt | 85 ++ .../util/lwjgl3/IsolatedClassLoader.java | 178 +++ .../util/lwjgl3/RelaunchClassLoader.java | 86 ++ .../gg/essential/util/lwjgl3/Lwjgl3Loader.kt | 77 ++ .../util/lwjgl3/api/NativeImageReader.kt | 48 + .../gg/essential/util/lwjgl3/api/TinyFd.kt | 22 + .../util/lwjgl3/api/nanovg/NanoVG.kt | 34 + .../util/lwjgl3/asm/GLBridgeTransformer.kt | 38 + mixin-compat/build.gradle.kts | 62 + .../gg/essential/CompatAccessTransformer.java | 28 + .../main/java/gg/essential/CompatMixin.java | 27 + .../main/java/gg/essential/CompatShadow.java | 24 + .../mixincompat/BundledAsmTransformer.java | 94 ++ .../mixincompat/CallbackInjectorCompat.java | 91 ++ .../mixincompat/GlobalPropertiesCompat.java | 47 + ...calVariableDiscriminatorContextCompat.java | 33 + .../essential/mixincompat/LocalsCompat.java | 193 +++ .../mixincompat/MixinConfigCompat.java | 28 + .../MixinPlatformManagerCompat.java | 31 + .../mixincompat/MixinProcessorCompat.java | 37 + .../MixinServiceLaunchWrapperCompat.java | 31 + .../mixincompat/MixinTransformerCompat.java | 49 + .../ModifyVariableInjectorCompat.java | 51 + .../mixincompat/TargetSelectorCompat.java | 34 + .../extensions/MixinConfigExt.java | 18 + .../mixincompat/util/MixinCompatUtils.java | 196 +++ plasmo/build.gradle.kts | 29 + .../essential/compat/PlasmoVoiceCompat.java | 58 + quic-connector/build.gradle.kts | 42 + .../main/java/gg/essential/quic/LogOnce.java | 53 + .../gg/essential/quic/LogOnceHandler.java | 48 + .../java/gg/essential/quic/ProxyHandler.java | 68 + .../essential/quic/QuicClientConnector.java | 298 +++++ .../java/gg/essential/quic/QuicConnector.java | 36 + .../essential/quic/QuicServerConnector.java | 221 ++++ root.gradle.kts | 62 + settings.gradle.kts | 102 ++ slf4j-to-log4j/README.md | 11 + slf4j-to-log4j/build.gradle.kts | 23 + .../main/java/org/slf4j/impl/Log4jLogger.java | 293 +++++ .../org/slf4j/impl/Log4jLoggerFactory.java | 37 + .../org/slf4j/impl/StaticLoggerBinder.java | 46 + .../java/org/slf4j/impl/StaticMDCBinder.java | 37 + .../org/slf4j/impl/StaticMarkerBinder.java | 45 + .../mods/core/tweaker/ModCoreTweaker.java | 30 + src/main/java/gg/essential/Essential.java | 503 ++++++++ .../api/tweaker/EssentialTweaker.java | 40 + .../essential/asm/EssentialTransformer.java | 22 + .../asm/compat/PhosphorTransformer.java | 57 + .../BetterFpsTransformerWrapper.java | 43 + .../tweaker/BetterFpsWrappingTweaker.java | 63 + .../commands/impl/CommandConfig.java | 31 + .../commands/impl/CommandInviteFriends.java | 28 + .../commands/impl/CommandMcFriends.java | 31 + .../commands/impl/CommandMessage.java | 79 ++ .../cosmetics/EssentialModelRenderer.java | 203 +++ .../cosmetics/PlayerWearableManager.java | 232 ++++ .../events/AnimationEffectHandler.java | 98 ++ .../skinmask/MaskedSkinProvider.java | 178 +++ .../cosmetics/skinmask/ModelMaskInstance.java | 24 + .../cosmetics/skinmask/SkinMasker.java | 218 ++++ .../cosmetics/skinmask/SkinRegion.java | 47 + .../source/ConfigurableCosmeticsSource.java | 43 + .../cosmetics/source/CosmeticsSource.java | 26 + .../cosmetics/source/LiveCosmeticsSource.java | 47 + .../gg/essential/dev/DevelopmentTweaker.java | 49 + .../gg/essential/event/CancellableEvent.java | 42 + .../java/gg/essential/event/EventHandler.java | 25 + .../event/client/ClientTickEvent.java | 20 + .../event/client/GameShutDownEvent.java | 19 + .../event/client/InitializationEvent.java | 19 + .../event/client/PostInitializationEvent.java | 15 + .../event/client/PreInitializationEvent.java | 19 + .../essential/event/client/ReAuthEvent.java | 27 + .../event/entity/PlayerTickEvent.java | 32 + .../event/essential/InitMainMenuEvent.java | 20 + .../event/essential/TosAcceptedEvent.java | 15 + .../gg/essential/event/gui/GuiClickEvent.java | 62 + .../event/gui/GuiDrawScreenEvent.java | 118 ++ .../essential/event/gui/GuiKeyTypedEvent.java | 47 + .../event/gui/GuiMouseReleaseEvent.java | 26 + .../gg/essential/event/gui/GuiOpenEvent.java | 37 + .../gg/essential/event/gui/InitGuiEvent.java | 55 + .../essential/event/gui/MouseScrollEvent.java | 36 + .../event/network/chat/ChatEvent.java | 33 + .../network/chat/SendChatMessageEvent.java | 32 + .../event/network/chat/SendCommandEvent.java | 32 + .../event/network/server/ServerJoinEvent.java | 33 + .../network/server/ServerLeaveEvent.java | 19 + .../network/server/SingleplayerJoinEvent.java | 19 + .../event/render/RenderTickEvent.java | 46 + .../event/sps/PlayerJoinSessionEvent.java | 27 + .../event/sps/PlayerLeaveSessionEvent.java | 27 + .../gg/essential/event/sps/SPSStartEvent.java | 25 + .../gg/essential/forge/EssentialForgeMod.java | 27 + .../screenshot/bytebuf/WrappedByteBuf.java | 1050 +++++++++++++++ .../handlers/EssentialChannelHandler.java | 18 + .../handlers/GameProfileManager.java | 295 +++++ .../essential/handlers/ModCompatBypass.java | 16 + .../essential/handlers/MojangSkinManager.java | 212 +++ .../gg/essential/handlers/NetworkHook.java | 41 + .../NetworkSubscriptionStateHandler.java | 99 ++ .../essential/handlers/OnlineIndicator.java | 286 +++++ .../gg/essential/handlers/ReAuthChecker.java | 32 + .../handlers/RenderPlayerBypass.java | 17 + .../handlers/ServerStatusHandler.java | 86 ++ .../gg/essential/handlers/ShutdownHook.java | 45 + .../handlers/WindowedFullscreenHandler.java | 93 ++ .../gg/essential/handlers/ZoomHandler.java | 188 +++ src/main/java/gg/essential/image/PNGFile.kt | 36 + .../image/imagescaling/AdvancedResizeOp.java | 67 + .../image/imagescaling/BSplineFilter.java | 56 + .../image/imagescaling/BellFilter.java | 54 + .../image/imagescaling/BiCubicFilter.java | 59 + .../imagescaling/BiCubicHighFreqResponse.java | 34 + .../image/imagescaling/BoxFilter.java | 44 + .../imagescaling/DimensionConstrain.java | 108 ++ .../image/imagescaling/HermiteFilter.java | 48 + .../image/imagescaling/Lanczos3Filter.java | 57 + .../image/imagescaling/MitchellFilter.java | 65 + .../image/imagescaling/ProgressListener.java | 22 + .../image/imagescaling/ResampleFilter.java | 26 + .../image/imagescaling/ResampleFilters.java | 39 + .../image/imagescaling/ResampleOp.java | 397 ++++++ .../image/imagescaling/TriangleFilter.java | 48 + .../gg/essential/key/EssentialKeybinding.java | 246 ++++ .../key/EssentialKeybindingRegistry.java | 223 ++++ .../java/gg/essential/main/Bootstrap.java | 121 ++ src/main/java/gg/essential/main/Main.java | 20 + .../java/gg/essential/mixins/DummyTarget.java | 19 + .../essential/mixins/MixinErrorHandler.java | 35 + src/main/java/gg/essential/mixins/Plugin.java | 222 ++++ .../gg/essential/mixins/impl/ClassHook.java | 21 + .../mixins/impl/client/MinecraftExt.java | 19 + .../mixins/impl/client/MinecraftHook.java | 76 ++ .../mixins/impl/client/audio/ISoundExt.java | 27 + .../entity/AbstractClientPlayerExt.java | 56 + .../gui/EssentialPostScreenDrawHook.java | 18 + .../impl/client/gui/GuiMainMenuHook.java | 34 + .../impl/client/gui/GuiNewChatHook.java | 30 + .../mixins/impl/client/gui/GuiScreenHook.java | 111 ++ .../impl/client/model/ModelBipedExt.java | 19 + .../impl/client/model/ModelBipedUtil.java | 88 ++ .../renderer/entity/ArmorRenderingUtil.java | 36 + .../resources/PlayerSkinProviderAccessor.java | 16 + .../impl/entity/player/EntityPlayerHook.java | 32 + .../impl/feature/ice/common/AgentExt.java | 28 + .../ice/common/MergingDatagramSocketExt.java | 20 + .../impl/feature/ice/common/StunStackExt.java | 18 + .../common/TricklingCandidateHarvester.java | 18 + .../ice/common/rtt/CandidatePairExt.java | 19 + .../common/rtt/StunServerTransactionExt.java | 19 + .../mixins/impl/forge/PlayerListHook.java | 20 + .../mixins/impl/util/math/Matrix3fExt.kt | 18 + .../mixins/impl/util/math/Matrix4fExt.kt | 18 + .../mixins/init/Mixin_MixIntoNonCoremods.java | 102 ++ .../injection/points/AfterInvokeInInit.java | 50 + .../points/BeforeConstantInInit.java | 43 + .../points/BeforeFieldAccessInInit.java | 44 + .../injection/points/BeforeInvokeInInit.java | 44 + .../gg/essential/mixins/modcompat/Plugin.java | 68 + .../cmm/CMMGuiConfigAccessor.java | 25 + .../cmm/MixinCMMGuiCustom_Events.java | 53 + ...in_BypassEpicFightMod_EmulatedPlayers.java | 30 + .../Mixin_EmulatedPlayerCapes.java | 34 + .../Mixin_EmulatedPlayerEars.java | 30 + .../neat/MixinHealthBarRenderer.java | 30 + .../MixinCustomLayerBipedArmor.java | 19 + .../oldanimationsmod/MixinModelMethods.java | 19 + .../transformers/client/MixinMinecraft.java | 285 +++++ .../Mixin_CancelVanillaScreenshotHotkey.java | 49 + .../client/Mixin_EmoteWheelUnPressKeys.java | 20 + .../client/Mixin_GuiKeyTypedEvent.java | 70 + .../client/Mixin_RunEssentialTasks.java | 76 ++ .../Mixin_SuppressScreenshotBufferFlip.java | 30 + .../client/Mixin_ThreadTaskExecutor.java | 22 + .../client/MouseHelperAccessor.java | 30 + ...ayNetHandlerMixin_CommandRegistration.java | 19 + .../entity/MixinAbstractClientPlayer.java | 279 ++++ .../client/gui/GuiMultiplayerAccessor.java | 31 + .../client/gui/GuiScreenAccessor.java | 52 + .../transformers/client/gui/MixinGuiChat.java | 43 + ...connected_AddInvalidSessionRefreshGui.java | 147 +++ .../MixinGuiInventory_UI3DPlayerOffset.java | 19 + .../client/gui/MixinGuiMainMenu.java | 36 + .../client/gui/MixinGuiMultiplayer.java | 220 ++++ .../client/gui/MixinGuiNewChat.java | 42 + .../client/gui/MixinGuiPlayerTabOverlay.java | 98 ++ .../client/gui/MixinGuiScreen.java | 96 ++ .../gui/MixinServerListEntryLanScan.java | 30 + .../gui/MixinServerListEntryNormal.java | 186 +++ .../client/gui/Mixin_BypassModCompat_Gui.java | 120 ++ .../client/gui/Mixin_ChatPeek.java | 53 + .../Mixin_FixEnterToConnectInCustomTabs.java | 45 + .../Mixin_FixKeyBindConflictInEmoteWheel.java | 37 + .../Mixin_FixKeybindConsumedInEmoteWheel.java | 32 + ...Mixin_FixKeybindUnpressedInEmoteWheel.java | 32 + ...Mixin_FixPlayerInventoryLightingOn116.java | 19 + ...in_PreventMovingOfServersInCustomTabs.java | 39 + .../gui/Mixin_RecalculateMenuScale.java | 19 + .../client/gui/Mixin_RemoveChatLimit.java | 38 + .../client/gui/Mixin_ScaleForMainMenu.java | 29 + ...xin_SkipLanScanningEntryForCustomTabs.java | 20 + .../Mixin_UI3DPlayer_FixNameTagRotation.java | 62 + ..._UnfocusTextFieldWhileOverlayHasFocus.java | 36 + .../gui/ServerListEntryNormalAccessor.java | 40 + .../gui/ServerSelectionListAccessor.java | 38 + .../Mixin_DisableCosmeticsInInventory.java | 49 + ..._ExtraTransform_CopyBetweenModelParts.java | 28 + .../client/model/ModelPlayerAccessor.java | 26 + .../geom/Mixin_ExtraTransform_ModelPart.java | 99 ++ ...ostRecentlyConnectedMultiplayerServer.java | 62 + .../client/multiplayer/MixinServerData.java | 108 ++ .../network/CPacketChatMessageAccessor.java | 26 + .../client/network/MixinServerPinger.java | 87 ++ .../client/network/Mixin_JoinEvent.java | 50 + .../client/network/Mixin_LogExceptions.java | 39 + .../Mixin_RedirectToLocalConnection.java | 93 ++ .../network/Mixin_RefreshSkinOnChange.java | 91 ++ .../Mixin_RegisterEssentialChannel.java | 57 + .../client/network/Mixin_SendChatEvents.java | 79 ++ .../network/Mixin_TabListNameIdCache.java | 72 ++ .../client/options/GameOptionsAccessor.java | 30 + .../client/options/KeyBindingAccessor.java | 42 + .../client/options/MixinGameOptions.java | 49 + .../renderer/MixinEntityRenderer_Zoom.java | 32 + .../renderer/MixinPlayerSkinTexture.java | 39 + .../renderer/Mixin_HideHandWhenZoomed.java | 36 + .../client/renderer/entity/MixinRender.java | 27 + .../renderer/entity/MixinRenderPlayer.java | 185 +++ .../entity/MixinRendererLivingEntity.java | 66 + .../entity/Mixin_ApplyPoseTransform.java | 74 ++ .../entity/Mixin_ApplyPoseTransform_Cape.java | 205 +++ .../Mixin_ApplyPoseTransform_Elytra.java | 92 ++ ...n_ApplyPoseTransform_EntityOnShoulder.java | 210 +++ .../Mixin_CosmeticHoverOutline_Cape.java | 142 ++ .../entity/Mixin_DisableArmorRendering.java | 42 + .../entity/Mixin_DisableElytraRendering.java | 32 + .../Mixin_DisablePlayerSkullRendering.java | 75 ++ ...ixin_DontRenderHitboxOnEmulatedPlayer.java | 30 + .../entity/Mixin_RenderNameplateIcon.java | 35 + .../Mixin_RenderNameplateIcon_Sneaking.java | 20 + .../entity/Mixin_StoreArmorRenderedState.java | 51 + ...roundIncorrectFrustumCheckAtHighSpeed.java | 46 + .../resources/Mixin_InstantSkinLoad.java | 75 ++ .../PlayerSkinProviderAccessorMixin.java | 20 + .../SkinProviderFileCacheAccessor.java | 20 + .../client/settings/MixinKeyBinding.java | 45 + .../Mixin_UnbindConflictingKeybinds.java | 97 ++ .../client/shader/MixinFramebuffer.java | 78 ++ ...yMenuScreenBackgroundRenderedEventAcc.java | 19 + .../fancymenu/KonkreteDrawScreenEventAcc.java | 62 + .../fancymenu/KonkreteGuiScreenEventAcc.java | 35 + ...inMenu_2_14_10_GuiDrawScreenEvent_Pre.java | 20 + ...FancyMainMenu_GuiDrawScreenEvent_Post.java | 62 + ..._FancyMainMenu_GuiDrawScreenEvent_Pre.java | 48 + .../forge/Mixin_FixInternalByteBufAccess.java | 21 + .../forge/Mixin_FixPrematureByteBufFree.java | 21 + .../labymod/MixinCustomMainMenu.java | 21 + .../labymod/MixinModGuiMultiplayer.java | 33 + .../labymod/MixinModelCosmetics.java | 44 + .../labymod/MixinMultiplayerTabs.java | 54 + .../labymod/MixinRenderPlayerCustom.java | 41 + .../minecraftcapes/MixinPlayerHandler.java | 49 + .../AbstractWrapperAccessor.java | 23 + .../MixinArmTransformer.java | 54 + .../MixinPlayerTransformer.java | 31 + .../quark/Mixin_NukeModelReset.java | 44 + .../compatibility/sba/MixinTabLine.java | 41 + .../sba/MixinTabListRenderer.java | 58 + .../Mixin_EmulatedPlayerCompat.java | 32 + ...FixArrowsStuckInUnusedPlayerModelPart.java | 89 ++ .../Mixin_FixBufferBuilderSortReset.java | 21 + ...n_FixPacketHandlingPastServerShutdown.java | 52 + .../vanilla/Mixin_FixResourcePackCrash.java | 20 + ...rkaroundBrokenFramebufferBlitBlending.java | 20 + .../Mixin_FixServerDataNotReset_OnCancel.java | 43 + ...in_FixServerDataNotReset_OnDisconnect.java | 35 + .../vanilla/serverdatareset/package-info.java | 17 + .../Mixin_RedirectVanillaCapeSetting.java | 72 ++ .../skinmask/Mixin_ApplyToHandRenderer.java | 38 + .../skinmask/Mixin_ApplyToPlayerRenderer.java | 29 + .../transformers/crash/MixinCrashReport.java | 39 + .../crash/MixinCrashReportCategory.java | 35 + .../entity/player/EntityPlayerAccessor.java | 30 + .../entity/player/MixinEntityPlayer.java | 35 + .../events/Mixin_GuiClickEvent.java | 38 + .../events/Mixin_GuiClickEvent_Priority.java | 49 + .../Mixin_GuiDrawScreenEvent_Priority.java | 189 +++ .../events/Mixin_GuiMouseReleaseEvent.java | 34 + .../events/Mixin_MouseScrollEvent.java | 43 + .../events/Mixin_RenderTickEvent.java | 71 + .../feature/chat/Mixin_ChatPeekScrolling.java | 44 + .../Mixin_UpdateOffscreenPlayers.java | 86 ++ .../Mixin_TrackClientDifficultyChanges.java | 83 ++ .../feature/difficulty/package-info.java | 22 + ...ixin_BroadcastServerDifficultyChanges.java | 35 + .../Mixin_RemoveUnsafeDifficultyChecks.java | 41 + .../server/Mixin_UseDifficultyFromLevel.java | 75 ++ ...vementDuringEmoteWheel_HandleKeybinds.java | 20 + ...owMovementDuringEmoteWheel_HandleKeys.java | 20 + .../gamerules/MixinGameRulesAccessor.java | 34 + .../MixinGameRulesValueAccessor.java | 27 + .../Mixin_IceAddressResolving_Connect.java | 59 + .../Mixin_IceAddressResolving_Ping.java | 60 + .../Mixin_FixIce4JDroppingValidPairs.java | 48 + .../Mixin_FixIce4JKeepAliveScheduling.java | 33 + .../Mixin_FixIce4JTimeoutCancellation.java | 83 ++ .../ice/common/Mixin_FixIce4JTurnTimeout.java | 25 + .../common/Mixin_IceChannelIsNotLocal.java | 44 + .../Mixin_WorkaroundWindowsUdpReset.java | 55 + .../ice/common/ice4j/IceMediaStreamAcc.java | 22 + .../ice/common/ice4j/Mixin_AgentExt.java | 57 + .../Mixin_CleanupUnusedRelayCandidates.java | 75 ++ .../Mixin_CloseRelayedAddressesFirst.java | 53 + .../Mixin_CountSentAndReceivedBytes.java | 65 + ...FixIce4JDataRaceInStunRequestHandling.java | 87 ++ .../ice4j/Mixin_FixIce4JLocalTrickling.java | 160 +++ .../ice4j/Mixin_RememberRelayedAddresses.java | 40 + ...n_AllowForImmediateCandidateTrickling.java | 39 + ...ixin_ImmediatelyTrickleStunCandidates.java | 85 ++ .../Mixin_RedirectIce4jJulLoggers.java | 112 ++ .../common/rtt/Mixin_CandidatePairExt.java | 36 + .../ice/common/rtt/Mixin_DecodeAttribute.java | 33 + .../rtt/Mixin_HandleAttributeOnClient.java | 83 ++ .../rtt/Mixin_HandleAttributeOnServer.java | 54 + ...Mixin_StoreRequestInServerTransaction.java | 38 + .../rtt/Mixin_StoreRttInCandidatePair.java | 46 + .../rtt/Mixin_StunServerTransactionExt.java | 35 + .../ice/server/Mixin_NettyIceServer.java | 75 ++ .../Mixin_AddParticleSystemToClientWorld.java | 38 + ...eserveRealYawDuringInventoryRendering.java | 56 + ...xin_RenderParticleSystemOfClientWorld.java | 210 +++ .../Mixin_AddPerServerPrivacyButton.java | 196 +++ .../Mixin_PreserveCustomServerData.java | 200 +++ .../Mixin_InstallTrustingServicesKeyInfo.java | 18 + .../sound/Mixin_ISoundExt_createAccessor.java | 19 + ..._ISoundExt_isRelativeToListener_apply.java | 44 + ...SoundExt_isRelativeToListener_forward.java | 32 + .../sound/Mixin_ISoundExt_maxDistance.java | 30 + .../sound/Mixin_LoadFromEssentialAsset.java | 22 + .../Mixin_IntegratedServerResourcePack.java | 19 + .../sps/Mixin_PlayerJoinSessionEvent.java | 61 + .../sps/Mixin_PlayerLeaveSessionEvent.java | 38 + .../sps/Mixin_ResolveSpsResourcePackUrl.java | 52 + .../forge/MixinJarDiscoverer.java | 37 + .../forge/MixinLoadController.java | 45 + .../forge/MixinNetworkModHolder.java | 42 + .../forge/Mixin_BypassModCompat_Check.java | 48 + .../forge/Mixin_BypassModCompat_Packet.java | 45 + ...xin_FixRaceConditionInServerHandshake.java | 65 + .../Mixin_PreventExtraPlayerLoadEvent.java | 55 + .../transformers/item/MixinElytraItem.java | 26 + .../AbstractResourcePackMixin_Ext.java | 33 + .../DirectoryResourcePackMixin_Ext.java | 19 + .../resources/LegacyAdapterPackMixin_Ext.java | 37 + .../LegacyV4AdapterPackMixin_Ext.java | 19 + .../resources/MixinResourcePackList.java | 55 + .../resources/ZipFileWrapperAccessor.java | 20 + .../resources/ZipResourcePackMixin_Ext.java | 19 + .../server/GameRulesAccessor.java | 19 + .../server/GameRulesBooleanValueAccessor.java | 19 + ...ameRulesMixin_RegisterCustomGameRules.java | 32 + .../MinecraftServerMixin_PvPGameRule.java | 40 + .../Mixin_ControlAreCommandsAllowed.java | 20 + .../Mixin_InvertPlayerDataPriority.java | 86 ++ .../Mixin_PublishServerStatusResponse.java | 74 ++ .../integrated/LanConnectionsAccessor.java | 34 + .../integrated/MixinIntegratedServer.java | 116 ++ .../Mixin_FixDefaultOpPermissionLevel.java | 20 + .../transformers/util/MixinTabCompleter.java | 48 + .../util/Mixin_CaptureScreenshots.java | 111 ++ .../util/math/Mixin_Matrix3fExt.java | 19 + .../util/math/Mixin_Matrix4fExt.java | 19 + .../gg/essential/model/PlayerMolangQuery.java | 161 +++ .../network/client/MinecraftHook.java | 24 + .../AsyncResponseHandler.java | 20 + .../connectionmanager/CloseReason.java | 37 + .../network/connectionmanager/Connection.java | 237 ++++ .../connectionmanager/ConnectionCodec.java | 177 +++ .../connectionmanager/ConnectionManager.java | 385 ++++++ .../EarlyResponseHandler.java | 20 + .../connectionmanager/NetworkedManager.java | 25 + .../StateCallbackManager.java | 41 + .../connectionmanager/UnknownPacket.java | 24 + .../connectionmanager/chat/ChatManager.java | 839 ++++++++++++ .../cosmetics/CosmeticsManager.java | 584 +++++++++ .../cosmetics/PacketHandlers.java | 170 +++ .../handler/PacketHandler.java | 53 + .../ChatChannelMemberAddPacketHandler.java | 31 + .../ChatChannelMemberRemovePacketHandler.java | 31 + ...ChatChannelMessageDeletePacketHandler.java | 30 + .../chat/ChatChannelUpdatePacketHandler.java | 28 + .../ServerChatChannelAddPacketHandler.java | 28 + .../ServerChatChannelClearPacketHandler.java | 28 + ...ServerChatChannelMessagePacketHandler.java | 150 +++ ...nnelMessageReportReasonsPacketHandler.java | 29 + .../ServerChatChannelRemovePacketHandler.java | 28 + ...rCheckoutPartnerCodeDataPacketHandler.java | 26 + .../ServerCoinsBalancePacketHandler.java | 26 + ...ientConnectionDisconnectPacketHandler.java | 27 + ...erverConnectionReconnectPacketHandler.java | 29 + ...CosmeticAnimationTriggerPacketHandler.java | 38 + ...smeticCategoriesPopulatePacketHandler.java | 34 + ...smeticEmoteWheelPopulatePacketHandler.java | 25 + ...erCosmeticOutfitPopulatePacketHandler.java | 54 + ...icOutfitSelectedResponsePacketHandler.java | 52 + ...erverCosmeticSkinTexturePacketHandler.java | 35 + ...verCosmeticTypesPopulatePacketHandler.java | 29 + .../ServerCosmeticsPopulatePacketHandler.java | 36 + ...rCosmeticsRevokePurchasePacketHandler.java | 72 ++ ...csUserEquippedVisibilityPacketHandler.java | 26 + ...verCosmeticsUserUnlockedPacketHandler.java | 62 + .../ServerWardrobeSettingsPacketHandler.java | 40 + .../ServerUuidNameMapPacketHandler.java | 31 + ...verMultiplayerJoinServerPacketHandler.java | 45 + .../ServerProfileActivityPacketHandler.java | 28 + .../ServerProfileStatusPacketHandler.java | 28 + ...ProfileTrustedHostsClearPacketHandler.java | 28 + ...fileTrustedHostsPopulatePacketHandler.java | 34 + ...rofileTrustedHostsRemovePacketHandler.java | 33 + ...endRequestPrivacySettingPacketHandler.java | 27 + ...ServerRelationshipDeletePacketHandler.java | 26 + ...rverRelationshipPopulatePacketHandler.java | 29 + .../ServerScreenshotListPacketHandler.java | 31 + .../ServerSkinPopulatePacketHandler.java | 31 + ...erverUPnPSessionPopulatePacketHandler.java | 34 + .../ServerUPnPSessionRemovePacketHandler.java | 35 + ...erverWardrobeStoreBundlePacketHandler.java | 31 + .../connectionmanager/ice/IceManager.java | 791 ++++++++++++ .../ice/Log4jAsJulLogger.java | 136 ++ .../ice/NominateBestRTT.java | 259 ++++ .../handler/IceCandidatePacketHandler.java | 33 + .../ice/handler/IceSessionPacketHandler.java | 56 + .../ice/netty/CloseAfterFirstMessage.java | 35 + .../netty/PseudoTcpChannelInitializer.java | 156 +++ .../netty/QuicStreamChannelInitializer.java | 151 +++ .../ice/util/CandidateUtil.java | 83 ++ ...WishMixinAllowedForPublicStaticFields.java | 21 + .../ice/util/StrictComparator.java | 37 + .../legacyjre/LegacyJre.java | 17 + .../legacyjre/LegacyJreDnsResolver.java | 42 + .../legacyjre/LegacyJreSocketFactory.java | 83 ++ .../ScreenshotCollectionChangeEvent.java | 38 + .../media/ScreenshotManager.java | 879 +++++++++++++ .../media/ScreenshotUploadException.java | 23 + .../notices/NoticesManager.java | 394 ++++++ .../ServerNoticePopulatePacketHandler.java | 36 + .../ServerNoticeRemovePacketHandler.java | 36 + .../profile/ProfileManager.java | 301 +++++ .../connectionmanager/queue/PacketQueue.java | 25 + .../queue/SequentialPacketQueue.java | 143 +++ .../relationship/FriendRequestState.java | 19 + .../relationship/RelationshipManager.java | 502 ++++++++ .../relationship/RelationshipResponse.java | 71 + .../ServerDiscoveryManager.java | 118 ++ ...rServerDiscoveryPopulatePacketHandler.java | 37 + .../social/SocialManager.java | 115 ++ ...DiscordRequestJoinServerPacketHandler.java | 50 + ...cialInviteToServerCancelPacketHandler.java | 36 + .../sps/SPSConnectionTelemetry.java | 71 + .../connectionmanager/sps/SPSManager.java | 861 +++++++++++++ .../sps/SPSSessionSource.java | 19 + .../connectionmanager/sps/SPSState.java | 19 + .../subscription/SubscriptionManager.java | 102 ++ .../telemetry/TelemetryManager.java | 183 +++ .../gg/essential/util/ClientSideModUtil.java | 56 + src/main/java/gg/essential/util/Diamond.java | 65 + .../util/EssentialContainerUtil.java | 102 ++ .../java/gg/essential/util/JsonHolder.java | 268 ++++ .../java/gg/essential/util/LoginUtil.java | 54 + .../java/gg/essential/util/MixinUtils.java | 59 + .../gg/essential/util/Multithreading.java | 47 + .../java/gg/essential/util/OptiFineUtil.java | 46 + .../java/gg/essential/util/ProtocolUtils.java | 48 + .../java/gg/essential/util/Sk1erModUtils.java | 26 + .../java/gg/essential/util/TemporaryFile.java | 37 + src/main/java/gg/essential/util/UUIDUtil.java | 27 + src/main/java/gg/essential/util/Version.java | 65 + src/main/java/gg/essential/util/WebUtil.java | 69 + .../util/crash/StacktraceDeobfuscator.java | 172 +++ .../gg/essential/util/swing/SwingUtil.java | 158 +++ .../util/swing/components/CircleButton.java | 31 + .../util/swing/components/RoundBoi.java | 61 + .../util/swing/components/TitleBar.java | 68 + .../TransactionTransmitCounterAttribute.java | 91 ++ src/main/kotlin/gg/essential/DI.kt | 60 + .../commands/EssentialCommandRegistry.kt | 197 +++ .../commands/api/ArgumentQueueImpl.kt | 42 + .../api/WhitespaceSensitiveArgumentQueue.kt | 21 + .../brigadier/EssentialBrigadierArgument.kt | 12 + .../EssentialBrigadierIntegration.kt | 12 + .../brigadier/StringReaderArgumentQueue.kt | 12 + .../commands/engine/CommandParser.kt | 204 +++ .../commands/engine/DefaultArgumentParsers.kt | 210 +++ .../commands/impl/SPSHostCommands.kt | 273 ++++ .../essential/compatibility/ModMenuCompat.kt | 15 + .../difficulty/ClientDifficultyState.kt | 26 + .../compatibility/vanilla/difficulty/Net.kt | 32 + .../vanilla/difficulty/UpdateDifficulty.kt | 64 + .../difficulty/UpdateDifficultyLock.kt | 62 + .../gg/essential/config/EssentialConfig.kt | 772 +++++++++++ .../config/EssentialConfigApiImpl.kt | 59 + .../kotlin/gg/essential/config/GuiBuilder.kt | 247 ++++ .../kotlin/gg/essential/config/GuiFactory.kt | 37 + .../config/PassivePropertyCollector.kt | 20 + .../kotlin/gg/essential/config/Vigilant2.kt | 90 ++ .../cosmetics/essentialModelRenderer.kt | 68 + .../kotlin/gg/essential/data/ABTestingData.kt | 37 + src/main/kotlin/gg/essential/data/MenuData.kt | 87 ++ .../gg/essential/data/OnboardingData.kt | 144 +++ src/main/kotlin/gg/essential/data/SPSData.kt | 84 ++ .../kotlin/gg/essential/data/VersionData.kt | 108 ++ .../gg/essential/gui/InternalEssentialGUI.kt | 51 + .../gg/essential/gui/about/AboutMenu.kt | 117 ++ .../about/components/ChangelogComponent.kt | 175 +++ .../gui/about/components/ColoredDivider.kt | 100 ++ .../gui/about/components/LeftPane.kt | 156 +++ .../gg/essential/gui/about/components/Page.kt | 221 ++++ .../gui/about/components/RightPane.kt | 93 ++ .../essential/gui/about/components/TopMenu.kt | 122 ++ .../gui/about/components/VersionInfo.kt | 33 + .../account/MicrosoftUserAuthentication.kt | 322 +++++ .../account/factory/ActiveSessionFactory.kt | 23 + .../account/factory/InitialSessionFactory.kt | 23 + .../account/factory/ManagedSessionFactory.kt | 33 + .../factory/MicrosoftAccountSessionFactory.kt | 233 ++++ .../gui/account/factory/SessionFactory.kt | 19 + .../gg/essential/gui/api/ComponentFactory.kt | 65 + .../gui/common/CosmeticHoverOutlineEffect.kt | 265 ++++ .../essential/gui/common/CosmeticPreview.kt | 148 +++ .../gui/common/CosmeticRenderPreview.kt | 103 ++ .../gg/essential/gui/common/CosmeticToasts.kt | 81 ++ .../gui/common/EmulatedUI3DPlayer.kt | 499 ++++++++ .../gg/essential/gui/common/NoticeFlag.kt | 140 ++ .../gg/essential/gui/common/UI3DPlayer.kt | 924 +++++++++++++ .../gg/essential/gui/common/UIFrameBuffer.kt | 98 ++ .../gg/essential/gui/common/UINanoVG.kt | 81 ++ .../gui/common/modal/OpenLinkModal.kt | 84 ++ .../gui/elementa/VanillaButtonConstraint.kt | 67 + .../elementa/VanillaButtonGroupConstraint.kt | 94 ++ .../gui/emotes/EmoteEventListeners.kt | 164 +++ .../gg/essential/gui/emotes/EmoteWheel.kt | 421 ++++++ .../essential/gui/emotes/EmoteWheelEntry.kt | 220 ++++ .../gg/essential/gui/friends/SocialMenu.kt | 526 ++++++++ .../gg/essential/gui/friends/TabsSelector.kt | 100 ++ .../gui/friends/message/MessageInput.kt | 363 ++++++ .../gui/friends/message/MessageScreen.kt | 55 + .../gui/friends/message/MessageTitleBar.kt | 203 +++ .../gui/friends/message/MessageUtils.kt | 120 ++ .../gui/friends/message/OldMessageInput.kt | 159 +++ .../gui/friends/message/ReportMessageModal.kt | 96 ++ .../screenshot/RemoveableScreenshotPreview.kt | 66 + .../message/screenshot/ScreenshotAttacher.kt | 95 ++ .../ScreenshotAttachmentComponents.kt | 175 +++ .../screenshot/ScreenshotAttachmentManager.kt | 152 +++ .../screenshot/ScreenshotItemsSlider.kt | 114 ++ .../message/screenshot/ScreenshotPicker.kt | 308 +++++ .../message/screenshot/ScreenshotPreview.kt | 53 + .../screenshot/SelectableScreenshotPreview.kt | 99 ++ .../screenshot/SimpleScreenshotProvider.kt | 256 ++++ .../gui/friends/message/v2/ClientMessage.kt | 131 ++ .../gui/friends/message/v2/DateDividerImpl.kt | 52 + .../gui/friends/message/v2/GiftEmbedImpl.kt | 88 ++ .../gui/friends/message/v2/ImageEmbedImpl.kt | 415 ++++++ .../gui/friends/message/v2/InviteEmbedImpl.kt | 26 + .../gui/friends/message/v2/MessageLine.kt | 239 ++++ .../gui/friends/message/v2/MessageRef.kt | 95 ++ .../friends/message/v2/MessageWrapperImpl.kt | 486 +++++++ .../friends/message/v2/ParagraphLineImpl.kt | 151 +++ .../message/v2/ReplyableMessageInput.kt | 163 +++ .../message/v2/ReplyableMessageScreen.kt | 511 ++++++++ .../gui/friends/message/v2/SkinEmbedImpl.kt | 75 ++ .../friends/message/v2/UnreadDividerImpl.kt | 45 + .../gui/friends/modal/MakeGroupModal.kt | 89 ++ .../gui/friends/previews/BasicUserEntry.kt | 84 ++ .../gui/friends/previews/BlockedUserEntry.kt | 40 + .../gui/friends/previews/ChannelPreview.kt | 409 ++++++ .../gui/friends/previews/FriendStatus.kt | 103 ++ .../gui/friends/previews/FriendUserEntry.kt | 58 + .../gui/friends/previews/PendingUserEntry.kt | 117 ++ .../gui/friends/previews/SearchableItem.kt | 18 + .../gui/friends/previews/SortListener.kt | 17 + .../state/MessengerStateManagerImpl.kt | 370 ++++++ .../state/RelationshipStateManagerImpl.kt | 220 ++++ .../gui/friends/state/SocialStateManager.kt | 58 + .../gui/friends/state/SocialStates.kt | 243 ++++ .../friends/state/StatusStateManagerImpl.kt | 111 ++ .../gg/essential/gui/friends/tabs/ChatTab.kt | 177 +++ .../essential/gui/friends/tabs/FriendsTab.kt | 187 +++ .../gui/friends/tabs/TabComponent.kt | 62 + .../title/SocialTitleManagementActions.kt | 76 ++ .../friends/title/TitleManagementActions.kt | 114 ++ .../gui/image/EssentialAssetImageFactory.kt | 56 + .../kotlin/gg/essential/gui/menu/Account.kt | 106 ++ .../gg/essential/gui/menu/AccountManager.kt | 169 +++ .../gg/essential/gui/menu/LeftSideBar.kt | 268 ++++ .../gg/essential/gui/menu/RightSideBar.kt | 168 +++ .../menu/compact/CompactAccountSwitcher.kt | 172 +++ .../gui/menu/compact/CompactRightSideBar.kt | 117 ++ .../gui/menu/full/FullAccountSwitcher.kt | 208 +++ .../gui/menu/full/FullRightSideBar.kt | 195 +++ .../gui/modals/AccountNotValidModal.kt | 43 + .../essential/gui/modals/AddAccountModal.kt | 29 + .../gui/modals/CosmeticsLoadingModal.kt | 44 + .../gui/modals/DiscordActivityStatusModal.kt | 49 + .../gui/modals/EssentialAutoInstalledModal.kt | 140 ++ .../gui/modals/FeaturesEnabledModal.kt | 97 ++ .../gui/modals/FirewallBlockingModal.kt | 45 + .../gui/modals/NotAuthenticatedModal.kt | 73 ++ .../gg/essential/gui/modals/TOSModal.kt | 228 ++++ .../gui/modals/UpdateAvailableModal.kt | 102 ++ .../gui/modals/UpdateNotificationModal.kt | 119 ++ .../gui/modals/select/SelectModal.kt | 295 +++++ .../gg/essential/gui/modals/select/builder.kt | 399 ++++++ .../gui/modals/select/component/friend.kt | 32 + .../gui/modals/select/component/group.kt | 24 + .../multiplayer/EssentialMultiplayerGui.kt | 290 +++++ .../EssentialServerSelectionList.kt | 205 +++ .../gui/multiplayer/FriendsIndicator.kt | 144 +++ .../disconnect/InvalidSessionRefreshGui.kt | 214 ++++ .../gui/notification/Notification.kt | 286 +++++ .../gui/notification/Notifications.kt | 250 ++++ .../ConfirmDenyNotificationActionComponent.kt | 83 ++ .../content/CosmeticPreviewToastComponent.kt | 41 + .../content/SkinPreviewToastComponent.kt | 48 + .../essential/gui/notification/extensions.kt | 120 ++ .../gg/essential/gui/notification/helpers.kt | 28 + .../gui/notification/notificationLayout.kt | 265 ++++ .../essential/gui/notification/toastButton.kt | 55 + .../kotlin/gg/essential/gui/notifications.kt | 65 + .../essential/gui/overlay/EphemeralLayer.kt | 45 + .../kotlin/gg/essential/gui/overlay/Layer.kt | 71 + .../gg/essential/gui/overlay/LayerPriority.kt | 55 + .../essential/gui/overlay/OverlayManager.kt | 54 + .../gui/overlay/OverlayManagerImpl.kt | 512 ++++++++ .../essential/gui/overlay/PersistentLayer.kt | 17 + .../gg/essential/gui/screenshot/DateRange.kt | 73 ++ .../essential/gui/screenshot/ScreenshotId.kt | 58 + .../gui/screenshot/ScreenshotOverlay.kt | 591 +++++++++ .../screenshot/action/PostScreenshotAction.kt | 55 + .../screenshot/bytebuf/LimitedAllocator.kt | 162 +++ .../bytebuf/WorkStealingAllocator.kt | 43 + .../screenshot/components/EditorToolbar.kt | 650 ++++++++++ .../screenshot/components/FocusComponent.kt | 209 +++ .../components/FocusEditComponent.kt | 175 +++ .../components/FocusListComponent.kt | 269 ++++ .../components/InvalidScreenshotView.kt | 33 + .../components/ListViewComponent.kt | 319 +++++ .../components/ScreenshotBrowser.kt | 327 +++++ .../components/ScreenshotComponents.kt | 87 ++ .../components/ScreenshotDateGroup.kt | 121 ++ .../components/ScreenshotItemsSlider.kt | 109 ++ .../components/ScreenshotOptionsDropdown.kt | 131 ++ .../components/ScreenshotPreview.kt | 172 +++ .../components/ScreenshotProviderManager.kt | 427 ++++++ .../components/ScreenshotScrollComponent.kt | 670 ++++++++++ .../components/ScreenshotShareModal.kt | 65 + .../components/ScreenshotStateManager.kt | 98 ++ .../gui/screenshot/components/ShareButton.kt | 198 +++ .../concurrent/PrioritizedCallable.kt | 56 + .../concurrent/PriorityThreadPoolExecutor.kt | 133 ++ .../ScreenshotWorkerThreadFactory.kt | 31 + .../AspectPreservingFillConstraint.kt | 64 + .../screenshot/constraints/TileConstraint.kt | 44 + .../downsampling/BufferBackedImage.kt | 62 + .../gui/screenshot/downsampling/ErrorImage.kt | 39 + .../screenshot/downsampling/PixelBuffer.kt | 48 + .../gui/screenshot/editor/ScreenshotCanvas.kt | 542 ++++++++ .../gui/screenshot/editor/change/Change.kt | 20 + .../screenshot/editor/change/CropChange.kt | 27 + .../screenshot/editor/change/VectorStroke.kt | 34 + .../gui/screenshot/editor/tools/PenTool.kt | 105 ++ .../gui/screenshot/editor/tools/Tool.kt | 26 + .../handler/ScreenshotChecksumManager.kt | 159 +++ .../handler/ScreenshotMetadataManager.kt | 165 +++ .../screenshot/image/ForkedImageClipboard.kt | 82 ++ .../screenshot/image/PixelBufferTexture.kt | 114 ++ .../gui/screenshot/image/ScreenshotImage.kt | 86 ++ .../providers/CloudflareImageProvider.kt | 75 ++ .../providers/DiskWindowedImageProvider.kt | 67 + .../FileCachedWindowedImageProvider.kt | 146 +++ .../MinecraftWindowedTextureProvider.kt | 418 ++++++ .../PostProcessWindowedImageProvider.kt | 57 + .../PriorityDelegatedWindowProvider.kt | 52 + .../providers/ScopeExpansionWindowProvider.kt | 212 +++ .../ScopePreservingWindowedProvider.kt | 38 + .../providers/ThreadedWindowedProvider.kt | 143 +++ .../providers/TransitionWindowedProvider.kt | 82 ++ .../screenshot/providers/WindowProvider.kt | 74 ++ .../kotlin/gg/essential/gui/skin/skinUtils.kt | 188 +++ .../essential/gui/sps/InviteFriendsModal.kt | 700 ++++++++++ .../essential/gui/sps/WorldSelectionModal.kt | 311 +++++ .../gui/sps/WorldShareSettingsGui.kt | 91 ++ .../gui/sps/categories/GameRulesCategory.kt | 402 ++++++ .../PlayersAndPermissionsCategory.kt | 113 ++ .../gui/sps/categories/SPSSettingsCategory.kt | 101 ++ .../sps/categories/WorldSettingsCategory.kt | 77 ++ .../gg/essential/gui/sps/options/SpsOption.kt | 468 +++++++ .../kotlin/gg/essential/gui/state/Sale.kt | 33 + .../kotlin/gg/essential/gui/studio/Tag.kt | 49 + .../gui/vigilancev2/VigilanceV2SettingsGui.kt | 18 + .../essential/gui/wardrobe/EmoteWheelPage.kt | 225 ++++ .../kotlin/gg/essential/gui/wardrobe/Item.kt | 202 +++ .../gg/essential/gui/wardrobe/Wardrobe.kt | 507 ++++++++ .../gui/wardrobe/WardrobeCategory.kt | 158 +++ .../gui/wardrobe/WardrobeContainer.kt | 143 +++ .../essential/gui/wardrobe/WardrobeState.kt | 582 +++++++++ .../wardrobe/categories/CategoryComponent.kt | 223 ++++ .../gui/wardrobe/categories/categoryShared.kt | 34 + .../wardrobe/categories/featuredCategory.kt | 121 ++ .../wardrobe/categories/outfitsCategory.kt | 36 + .../gui/wardrobe/categories/skinsCategory.kt | 36 + .../gui/wardrobe/components/banner.kt | 113 ++ .../gui/wardrobe/components/coinPackImage.kt | 37 + .../gui/wardrobe/components/coinsButton.kt | 34 + .../gui/wardrobe/components/coinsText.kt | 53 + .../gui/wardrobe/components/cosmeticItem.kt | 812 ++++++++++++ .../cosmeticOrEmoteItemFunctions.kt | 384 ++++++ .../gui/wardrobe/components/cosmeticsGrid.kt | 35 + .../gui/wardrobe/components/gifting.kt | 267 ++++ .../gui/wardrobe/components/infoIcon.kt | 46 + .../gui/wardrobe/components/noItemsFound.kt | 31 + .../wardrobe/components/outfitComponents.kt | 71 + .../components/outfitItemFunctions.kt | 144 +++ .../gui/wardrobe/components/previewWindow.kt | 596 +++++++++ .../gui/wardrobe/components/sidebar.kt | 128 ++ .../wardrobe/components/skinItemFunctions.kt | 61 + .../configuration/AbstractConfiguration.kt | 171 +++ .../configuration/ConfigurationMenu.kt | 95 ++ .../configuration/ConfigurationType.kt | 174 +++ .../configuration/ConfigurationUtils.kt | 290 +++++ .../CosmeticBundleConfiguration.kt | 87 ++ .../CosmeticCategoryConfiguration.kt | 152 +++ .../configuration/CosmeticConfiguration.kt | 205 +++ .../CosmeticTypeConfiguration.kt | 57 + .../FeaturedPageCollectionConfiguration.kt | 276 ++++ .../CosmeticCategoriesConfiguration.kt | 83 ++ .../properties/ArmorHandlingConfiguration.kt | 44 + .../CosmeticBoneHidingConfiguration.kt | 190 +++ .../DefaultSidePropertyConfiguration.kt | 33 + .../ExternalHiddenBoneConfiguration.kt | 196 +++ .../InterruptsEmoteConfiguration.kt | 48 + .../properties/LocalizationConfiguration.kt | 36 + ...PositionAdjustmentPropertyConfiguration.kt | 57 + .../PreviewResetTimeConfiguration.kt | 34 + .../RequiresUnlockActionConfiguration.kt | 84 ++ .../SingletonPropertyConfiguration.kt | 66 + .../TransitionDelayConfiguration.kt | 34 + .../VariantsPropertyConfiguration.kt | 400 ++++++ ...rPositionAdjustmentSettingConfiguration.kt | 73 ++ .../settings/SideSettingConfiguration.kt | 45 + .../settings/SingletonSettingConfiguration.kt | 87 ++ .../settings/VariantSettingConfiguration.kt | 54 + .../gui/wardrobe/modals/CoinsPurchaseModal.kt | 217 ++++ .../gui/wardrobe/modals/CoinsReceivedModal.kt | 94 ++ .../wardrobe/modals/PurchaseConfirmModal.kt | 59 + .../gui/wardrobe/modals/SkinModal.kt | 353 +++++ .../gg/essential/gui/wardrobe/purchase.kt | 293 +++++ .../gui/wardrobe/something/CosmeticGroup.kt | 74 ++ .../essential/handlers/EssentialGameRules.kt | 61 + .../handlers/EssentialSoundManager.kt | 199 +++ .../handlers/OptionsScreenOverlay.kt | 108 ++ .../gg/essential/handlers/PauseMenuDisplay.kt | 324 +++++ .../essential/handlers/WikiToastListener.kt | 48 + .../handlers/account/WebAccountManager.kt | 254 ++++ .../handlers/discord/DiscordIntegration.kt | 357 ++++++ .../discord/activity/ActivityState.kt | 99 ++ .../provider/ActivityStateProvider.kt | 33 + .../impl/GameActivityStateProvider.kt | 58 + .../provider/impl/GuiActivityStateProvider.kt | 91 ++ .../handlers/discord/extensions/User.kt | 17 + .../discord/party/PartyInformation.kt | 27 + .../handlers/discord/party/PartyManager.kt | 137 ++ .../essential/handlers/io/DirectoryWatcher.kt | 150 +++ .../screenshot/ClientScreenshotMetadata.kt | 94 ++ .../handlers/screenshot/FileSystemEvent.kt | 50 + .../screenshot/ScreenshotUploadUtil.kt | 63 + .../mixins/ext/client/MinecraftExt.kt | 39 + .../mixins/ext/client/ParticleSystemHolder.kt | 18 + .../ext/client/gui/GuiMultiplayerExt.kt | 28 + .../mixins/ext/client/gui/GuiSlotExt.kt | 34 + .../mixins/ext/client/gui/LabyModMainMenu.kt | 16 + .../client/gui/ServerListEntryNormalExt.kt | 22 + .../client/model/geom/ExtraTransformHolder.kt | 18 + .../ext/client/multiplayer/ServerDataExt.kt | 52 + .../client/network/NetHandlerPlayClientExt.kt | 18 + .../client/renderer/PlayerSkinTextureExt.kt | 25 + .../client/resource/FileResourcePackExt.kt | 18 + .../ext/compatibility/CMMGuiCustomExt.kt | 21 + .../mixins/ext/network/NetworkSystemExt.kt | 23 + .../librarianlib/BitGridMixin.kt | 28 + .../kotlin/gg/essential/model/AnimatedCape.kt | 61 + .../minecraft/MinecraftRenderBackend.kt | 225 ++++ .../minecraft/WorldCollisionProvider.kt | 241 ++++ .../backend/minecraft/WorldLightProvider.kt | 37 + .../model/backend/minecraft/playerPose.kt | 171 +++ .../connectionmanager/ConnectionManagerKt.kt | 351 +++++ .../connectionmanager/coins/CoinsManager.kt | 311 +++++ .../cosmetics/AssetLoader.kt | 261 ++++ .../CosmeticEquipVisibilityResponse.kt | 48 + .../cosmetics/CosmeticsData.kt | 38 + .../cosmetics/CosmeticsDataWithChanges.kt | 139 ++ .../cosmetics/EquippedCosmeticsManager.kt | 218 ++++ .../cosmetics/InfraCosmeticsData.kt | 260 ++++ .../cosmetics/LocalCosmeticsData.kt | 301 +++++ .../cosmetics/ModelLoader.kt | 114 ++ .../cosmetics/MutableCosmeticsData.kt | 50 + .../cosmetics/OutfitManager.kt | 379 ++++++ .../cosmetics/WardrobeSettings.kt | 41 + .../cosmetics/connectionManager.kt | 95 ++ .../localCosmeticBundleManagement.kt | 57 + .../localCosmeticCategoryManagement.kt | 73 ++ .../cosmetics/localCosmeticManagement.kt | 247 ++++ .../localCosmeticPropertyManagement.kt | 24 + .../cosmetics/localCosmeticTypeManagement.kt | 36 + .../localFeaturedPageCollectionManagement.kt | 40 + ...ServerUPnPSessionInviteAddPacketHandler.kt | 77 ++ .../FriendRequestToastNoticeListener.kt | 76 ++ .../notices/GiftedCosmeticNoticeListener.kt | 39 + .../notices/NoticeBannerManager.kt | 89 ++ .../notices/PersistentToastNoticeListener.kt | 138 ++ ...SocialMenuNewFriendRequestNoticeManager.kt | 72 ++ .../relationship/RelationshipErrorResponse.kt | 108 ++ .../connectionmanager/skins/SkinsManager.kt | 174 +++ .../SocialInviteToServerPacketHandler.kt | 94 ++ .../telemetry/ImpressionTelemetryManager.kt | 46 + .../telemetry/telemetryManager.kt | 56 + .../cosmetics/cape/CapeCosmeticsManager.kt | 183 +++ .../network/cosmetics/cape/MojangCapeApi.kt | 96 ++ .../network/cosmetics/conversions.kt | 179 +++ .../network/pingproxy/ProxyPingServer.kt | 143 +++ .../render/TextRenderTypeVertexConsumer.kt | 115 ++ .../kotlin/gg/essential/sps/FirewallUtil.kt | 44 + .../sps/ResourcePackSharingHttpServer.kt | 221 ++++ .../gg/essential/sps/quic/QuicStream.kt | 26 + .../sps/quic/jvm/ForkedJvmClientQuicStream.kt | 102 ++ .../sps/quic/jvm/ForkedJvmQuicConnector.kt | 72 ++ .../quic/jvm/ForkedJvmServerQuicStreamPool.kt | 136 ++ .../kotlin/gg/essential/sps/quic/jvm/util.kt | 52 + .../kotlin/gg/essential/util/AddressUtil.kt | 50 + .../kotlin/gg/essential/util/AutoUpdate.kt | 233 ++++ .../kotlin/gg/essential/util/ForkedJvm.kt | 95 ++ src/main/kotlin/gg/essential/util/GLUtil.kt | 112 ++ .../kotlin/gg/essential/util/GlFrameBuffer.kt | 238 ++++ .../essential/util/GuiElementaPlatformImpl.kt | 33 + .../util/GuiEssentialPlatformImpl.kt | 143 +++ src/main/kotlin/gg/essential/util/GuiUtil.kt | 152 +++ .../gg/essential/util/LimitedExecutor.kt | 61 + .../util/MinecraftCoroutineDispatchers.kt | 32 + .../gg/essential/util/MinecraftUtils.kt | 270 ++++ .../kotlin/gg/essential/util/ModLoaderUtil.kt | 350 +++++ .../kotlin/gg/essential/util/MojangAPI.kt | 92 ++ .../kotlin/gg/essential/util/RefCounted.kt | 44 + .../gg/essential/util/ResourceManagerUtil.kt | 79 ++ .../gg/essential/util/ServerConnectionUtil.kt | 30 + .../gg/essential/util/SimpleObservableMap.kt | 63 + .../gg/essential/util/TargetThreadExecutor.kt | 44 + .../gg/essential/util/TrustedHostsUtil.kt | 48 + src/main/kotlin/gg/essential/util/Try.kt | 59 + src/main/kotlin/gg/essential/util/currency.kt | 29 + src/main/kotlin/gg/essential/util/eventbus.kt | 157 +++ .../util/eventbusCoroutineExtensions.kt | 31 + .../kotlin/gg/essential/util/extensions.kt | 467 +++++++ src/main/kotlin/gg/essential/util/helpers.kt | 544 ++++++++ src/main/kotlin/gg/essential/util/iterable.kt | 20 + src/main/kotlin/gg/essential/util/model.kt | 17 + .../resource/EssentialAssetResourcePack.kt | 135 ++ .../essential/util/resource/Lang3As2Pack.kt | 23 + .../essential/util/resource/Lang3As4Pack.kt | 44 + .../util/resource/ResourcePackAdapter.kt | 137 ++ src/main/kotlin/gg/essential/util/skin.kt | 38 + .../kotlin/gg/essential/util/uIdentifier.kt | 16 + src/main/resources/META-INF/mods.toml | 17 + .../essential/account/core.c28d5d87.css | 725 +++++++++++ .../assets/essential/account/error.html | 61 + .../essential/account/login/microsoft.html | 68 + .../essential/account/login/success.html | 43 + .../assets/essential/account/main.5a1fd7db.js | 144 +++ .../assets/essential/account/main.c766a760.js | 144 +++ .../essential/account/microsoft.2cc0fa02.js | 141 ++ .../minecraft-computer-login.5074e926.png | Bin 0 -> 2182324 bytes .../essential/certs/amazon-root-ca-1.der | Bin 0 -> 837 bytes .../certs/baltimore-cybertrust-root.der | Bin 0 -> 891 bytes .../certs/d-trust-root-class-3-ca-2-2009.der | Bin 0 -> 1079 bytes .../certs/digicert-global-root-ca.der | Bin 0 -> 947 bytes .../certs/digicert-global-root-g2.der | Bin 0 -> 914 bytes .../assets/essential/certs/gts-root-r1.der | Bin 0 -> 1374 bytes .../assets/essential/certs/isrgrootx1.der | Bin 0 -> 1391 bytes .../essential/certs/lets-encrypt-r3.der | Bin 0 -> 1306 bytes .../certs/microsoft-ecc-root-ca-2017.der | Bin 0 -> 605 bytes .../certs/microsoft-rsa-root-ca-2017.der | Bin 0 -> 1452 bytes .../assets/essential/lang/en_us.lang | 354 +++++ .../resources/assets/essential/sounds.json | 17 + .../assets/essential/sounds/coin_fill_up.ogg | Bin 0 -> 42241 bytes .../sounds/purchase_confirmation.ogg | Bin 0 -> 26691 bytes .../assets/essential/sounds/screenshot.ogg | Bin 0 -> 10416 bytes .../essential/textures/announcement_icon.png | Bin 0 -> 1426 bytes .../assets/essential/textures/arrow-left.png | Bin 0 -> 151 bytes .../essential/textures/arrow-left_5x7.png | Bin 0 -> 131 bytes .../assets/essential/textures/arrow-right.png | Bin 0 -> 138 bytes .../essential/textures/arrow-right_3x5.png | Bin 0 -> 104 bytes .../essential/textures/arrow-up-right.png | Bin 0 -> 82 bytes .../assets/essential/textures/cancel.png | Bin 0 -> 188 bytes .../essential/textures/cancel_10x10.png | Bin 0 -> 180 bytes .../essential/textures/cancel_12x12.png | Bin 0 -> 187 bytes .../assets/essential/textures/cancel_5x5.png | Bin 0 -> 140 bytes .../assets/essential/textures/cancel_7x7.png | Bin 0 -> 114 bytes .../assets/essential/textures/cart_12x12.png | Bin 0 -> 171 bytes .../essential/textures/checkmark_7x5.png | Bin 0 -> 89 bytes .../essential/textures/checkmark_8x6.png | Bin 0 -> 102 bytes .../textures/coin/coin_bundle_0_999.png | Bin 0 -> 820 bytes .../essential/textures/coin/coin_icon.png | Bin 0 -> 175 bytes .../essential/textures/cosmetics_off_10x7.png | Bin 0 -> 127 bytes .../essential/textures/download_7x8.png | Bin 0 -> 156 bytes .../textures/dropdown/arrow_down.png | Bin 0 -> 136 bytes .../essential/textures/dropdown/arrow_up.png | Bin 0 -> 139 bytes .../essential/textures/ellipses_5x1.png | Bin 0 -> 98 bytes .../essential/textures/envelope_10x7.png | Bin 0 -> 114 bytes .../essential/textures/envelope_9x7.png | Bin 0 -> 163 bytes .../assets/essential/textures/essential.png | Bin 0 -> 1045 bytes .../assets/essential/textures/filter.png | Bin 0 -> 139 bytes .../essential/textures/friends/burger.png | Bin 0 -> 134 bytes .../essential/textures/friends/group_blue.png | Bin 0 -> 170 bytes .../textures/friends/group_green.png | Bin 0 -> 172 bytes .../textures/friends/group_purple.png | Bin 0 -> 172 bytes .../essential/textures/friends/group_red.png | Bin 0 -> 170 bytes .../textures/friends/group_yellow.png | Bin 0 -> 170 bytes .../essential/textures/friends/join_arrow.png | Bin 0 -> 144 bytes .../essential/textures/friends/make_group.png | Bin 0 -> 164 bytes .../essential/textures/friends/reply.png | Bin 0 -> 149 bytes .../essential/textures/friends/reply_left.png | Bin 0 -> 106 bytes .../assets/essential/textures/heart.png | Bin 0 -> 123 bytes .../assets/essential/textures/info_9x9.png | Bin 0 -> 107 bytes .../assets/essential/textures/kick.png | Bin 0 -> 208 bytes .../assets/essential/textures/link.png | Bin 0 -> 1304 bytes .../essential/textures/lock_hollow_7x9.png | Bin 0 -> 160 bytes .../essential/textures/menu/arrow_down.png | Bin 0 -> 140 bytes .../essential/textures/menu/arrow_up.png | Bin 0 -> 141 bytes .../assets/essential/textures/menu/bell.png | Bin 0 -> 124 bytes .../essential/textures/menu/cosmetics.png | Bin 0 -> 132 bytes .../essential/textures/menu/cosmetics_off.png | Bin 0 -> 1473 bytes .../essential/textures/menu/cosmetics_on.png | Bin 0 -> 1472 bytes .../assets/essential/textures/menu/emotes.png | Bin 0 -> 133 bytes .../essential/textures/menu/essential.png | Bin 0 -> 118 bytes .../assets/essential/textures/menu/expand.png | Bin 0 -> 147 bytes .../essential/textures/menu/friends.png | Bin 0 -> 136 bytes .../essential/textures/menu/fullscreen.png | Bin 0 -> 137 bytes .../textures/menu/fullscreen_off.png | Bin 0 -> 150 bytes .../essential/textures/menu/fullscreen_on.png | Bin 0 -> 147 bytes .../assets/essential/textures/menu/invite.png | Bin 0 -> 155 bytes .../assets/essential/textures/menu/logo.png | Bin 0 -> 156 bytes .../essential/textures/menu/mc_folder.png | Bin 0 -> 150 bytes .../essential/textures/menu/messages.png | Bin 0 -> 128 bytes .../assets/essential/textures/menu/mods.png | Bin 0 -> 126 bytes .../textures/menu/notifications_off.png | Bin 0 -> 1472 bytes .../textures/menu/notifications_on.png | Bin 0 -> 1471 bytes .../essential/textures/menu/pictures.png | Bin 0 -> 158 bytes .../textures/menu/pictures_short.png | Bin 0 -> 160 bytes .../essential/textures/menu/radio_tick.png | Bin 0 -> 144 bytes .../essential/textures/menu/settings.png | Bin 0 -> 150 bytes .../essential/textures/menu/settings_9x8.png | Bin 0 -> 150 bytes .../textures/menu/settings_vertical.png | Bin 0 -> 156 bytes .../assets/essential/textures/menu/social.png | Bin 0 -> 157 bytes .../essential/textures/menu/star_4x3.png | Bin 0 -> 102 bytes .../assets/essential/textures/menu/trash.png | Bin 0 -> 162 bytes .../essential/textures/menu/world_8x8.png | Bin 0 -> 112 bytes .../essential/textures/notice_11x11.png | Bin 0 -> 107 bytes .../essential/textures/optionmenu/block.png | Bin 0 -> 176 bytes .../textures/optionmenu/block7x7.png | Bin 0 -> 182 bytes .../essential/textures/optionmenu/copy.png | Bin 0 -> 171 bytes .../essential/textures/optionmenu/cut.png | Bin 0 -> 169 bytes .../textures/optionmenu/edit_short.png | Bin 0 -> 175 bytes .../essential/textures/optionmenu/folder.png | Bin 0 -> 164 bytes .../textures/optionmenu/join_arrow.png | Bin 0 -> 147 bytes .../essential/textures/optionmenu/leave.png | Bin 0 -> 168 bytes .../essential/textures/optionmenu/link.png | Bin 0 -> 168 bytes .../textures/optionmenu/mark_unread.png | Bin 0 -> 168 bytes .../essential/textures/optionmenu/message.png | Bin 0 -> 160 bytes .../essential/textures/optionmenu/mute.png | Bin 0 -> 168 bytes .../textures/optionmenu/mute_new.png | Bin 0 -> 167 bytes .../essential/textures/optionmenu/paste.png | Bin 0 -> 156 bytes .../essential/textures/optionmenu/pencil.png | Bin 0 -> 170 bytes .../textures/optionmenu/properties.png | Bin 0 -> 148 bytes .../textures/optionmenu/remove_friend.png | Bin 0 -> 149 bytes .../optionmenu/remove_friend_player.png | Bin 0 -> 194 bytes .../essential/textures/optionmenu/rename.png | Bin 0 -> 152 bytes .../essential/textures/optionmenu/reply.png | Bin 0 -> 152 bytes .../essential/textures/optionmenu/report.png | Bin 0 -> 166 bytes .../essential/textures/optionmenu/social.png | Bin 0 -> 159 bytes .../essential/textures/optionmenu/unmute.png | Bin 0 -> 165 bytes .../textures/optionmenu/unmute_new.png | Bin 0 -> 157 bytes .../essential/textures/pack_128x128.png | Bin 0 -> 6247 bytes .../assets/essential/textures/person_4x6.png | Bin 0 -> 170 bytes .../assets/essential/textures/plus.png | Bin 0 -> 156 bytes .../assets/essential/textures/plus_10x10.png | Bin 0 -> 2685 bytes .../assets/essential/textures/plus_5x5.png | Bin 0 -> 136 bytes .../assets/essential/textures/plus_7x7.png | Bin 0 -> 139 bytes .../essential/textures/power-button_7x7.png | Bin 0 -> 108 bytes .../assets/essential/textures/properties.png | Bin 0 -> 138 bytes .../assets/essential/textures/retry_7x7.png | Bin 0 -> 161 bytes .../essential/textures/round_warning.png | Bin 0 -> 152 bytes .../essential/textures/sale_indicator.png | Bin 0 -> 225 bytes .../essential/textures/screenshots/edit.png | Bin 0 -> 213 bytes .../textures/screenshots/favorite.png | Bin 0 -> 192 bytes .../essential/textures/screenshots/file.png | Bin 0 -> 181 bytes .../textures/screenshots/folder_10x.png | Bin 0 -> 1037 bytes .../textures/screenshots/folder_16x.png | Bin 0 -> 170 bytes .../essential/textures/screenshots/link.png | Bin 0 -> 207 bytes .../textures/screenshots/new/copy.png | Bin 0 -> 165 bytes .../textures/screenshots/new/download.png | Bin 0 -> 156 bytes .../textures/screenshots/new/edit.png | Bin 0 -> 171 bytes .../textures/screenshots/new/folder.png | Bin 0 -> 154 bytes .../textures/screenshots/new/heart_filled.png | Bin 0 -> 161 bytes .../screenshots/new/heart_outline.png | Bin 0 -> 155 bytes .../screenshots/new/image_size_big.png | Bin 0 -> 152 bytes .../screenshots/new/image_size_small.png | Bin 0 -> 153 bytes .../textures/screenshots/new/link.png | Bin 0 -> 155 bytes .../textures/screenshots/new/redo.png | Bin 0 -> 157 bytes .../textures/screenshots/new/save.png | Bin 0 -> 166 bytes .../textures/screenshots/new/undo.png | Bin 0 -> 156 bytes .../textures/screenshots/new/upload.png | Bin 0 -> 155 bytes .../textures/screenshots/options.png | Bin 0 -> 136 bytes .../textures/screenshots/trash_can_10x.png | Bin 0 -> 1041 bytes .../textures/screenshots/trash_can_16x.png | Bin 0 -> 167 bytes .../assets/essential/textures/search.png | Bin 0 -> 180 bytes .../assets/essential/textures/search_7x.png | Bin 0 -> 150 bytes .../assets/essential/textures/sps/crown.png | Bin 0 -> 148 bytes .../assets/essential/textures/sps/op.png | Bin 0 -> 151 bytes .../assets/essential/textures/sps/pinned.png | Bin 0 -> 159 bytes .../essential/textures/sps/reinvite_5x5.png | Bin 0 -> 106 bytes .../essential/textures/sps/unpinned.png | Bin 0 -> 161 bytes .../textures/studio/20_percent_sale.png | Bin 0 -> 170 bytes .../essential/textures/studio/add_to_cart.png | Bin 0 -> 175 bytes .../textures/studio/animation_off.png | Bin 0 -> 177 bytes .../textures/studio/animation_on.png | Bin 0 -> 177 bytes .../assets/essential/textures/studio/cart.png | Bin 0 -> 182 bytes .../essential/textures/studio/cart_8x7.png | Bin 0 -> 166 bytes .../essential/textures/studio/clock_1.png | Bin 0 -> 154 bytes .../essential/textures/studio/clock_2.png | Bin 0 -> 157 bytes .../essential/textures/studio/clock_3.png | Bin 0 -> 155 bytes .../essential/textures/studio/clock_4.png | Bin 0 -> 156 bytes .../essential/textures/studio/featured.png | Bin 0 -> 174 bytes .../assets/essential/textures/studio/gift.png | Bin 0 -> 192 bytes .../assets/essential/textures/studio/grid.png | Bin 0 -> 120 bytes .../assets/essential/textures/studio/hat.png | Bin 0 -> 175 bytes .../essential/textures/studio/heart_7x6.png | Bin 0 -> 106 bytes .../assets/essential/textures/studio/lock.png | Bin 0 -> 154 bytes .../essential/textures/studio/lock_closed.png | Bin 0 -> 190 bytes .../essential/textures/studio/lock_open.png | Bin 0 -> 197 bytes .../essential/textures/studio/new_22x11.png | Bin 0 -> 172 bytes .../essential/textures/studio/partner.png | Bin 0 -> 1751 bytes .../textures/studio/partner_small.png | Bin 0 -> 1712 bytes .../essential/textures/studio/play_arrow.png | Bin 0 -> 1698 bytes .../textures/studio/remove_from_cart.png | Bin 0 -> 179 bytes .../essential/textures/studio/rotate_left.png | Bin 0 -> 154 bytes .../textures/studio/rotate_right.png | Bin 0 -> 155 bytes .../assets/essential/textures/studio/row.png | Bin 0 -> 113 bytes .../assets/essential/textures/studio/skin.png | Bin 0 -> 174 bytes .../essential/textures/studio/sliders.png | Bin 0 -> 1723 bytes .../essential/textures/studio/upload-skin.png | Bin 0 -> 1257 bytes .../assets/essential/textures/toggle/off.png | Bin 0 -> 143 bytes .../assets/essential/textures/toggle/on.png | Bin 0 -> 124 bytes .../essential/textures/turn_left_18x18.png | Bin 0 -> 192 bytes .../essential/textures/turn_right_18x18.png | Bin 0 -> 189 bytes .../textures/wardrobe/character_4x6.png | Bin 0 -> 104 bytes .../textures/wardrobe/emote_wheel.png | Bin 0 -> 138 bytes .../essential/textures/wardrobe/gift.png | Bin 0 -> 150 bytes .../essential/textures/wardrobe/star.png | Bin 0 -> 157 bytes .../assets/essential/textures/white.png | Bin 0 -> 542 bytes .../assets/essential/textures/wip.png | Bin 0 -> 18994 bytes .../resources/assets/essential/version.txt | 1 + src/main/resources/fabric.mod.json | 28 + src/main/resources/mcmod.info | 18 + src/main/resources/mixins.essential.init.json | 16 + src/main/resources/mixins.essential.json | 221 ++++ .../resources/mixins.essential.modcompat.json | 22 + src/main/resources/pack.mcmeta | 6 + utils/build.gradle.kts | 31 + .../kotlin/gg/essential/data/VersionInfo.kt | 33 + .../kotlin/gg/essential/model/util/Color.kt | 58 + .../gg/essential/util/OperatingSystem.kt | 29 + .../kotlin/gg/essential/util/UIdentifier.kt | 18 + .../gg/essential/model/util/jvmColor.kt | 16 + versions/1.12.2-1.8.9.txt | 51 + versions/1.15.2-1.12.2.txt | 14 + versions/1.16.2-1.12.2.txt | 179 +++ .../essential/compatibility/ModMenuCompat.kt | 24 + .../handlers/WindowedFullscreenHandler.java | 17 + .../mixins/init/Mixin_MixIntoNonCoremods.java | 19 + .../cmm/MixinCMMGuiCustom_Events.java | 20 + .../Mixin_EmulatedPlayerCapes.java | 19 + .../Mixin_EmulatedPlayerEars.java | 19 + .../Mixin_CancelVanillaScreenshotHotkey.java | 41 + .../client/Mixin_GuiKeyTypedEvent.java | 204 +++ .../Mixin_SuppressScreenshotBufferFlip.java | 36 + .../client/Mixin_ThreadTaskExecutor.java | 28 + ...ayNetHandlerMixin_CommandRegistration.java | 36 + .../MixinGuiInventory_UI3DPlayerOffset.java | 65 + .../Mixin_FixKeybindConsumedInEmoteWheel.java | 19 + ...Mixin_FixPlayerInventoryLightingOn116.java | 60 + ...in_PreventMovingOfServersInCustomTabs.java | 68 + .../gui/Mixin_RecalculateMenuScale.java | 61 + .../client/gui/Mixin_ScaleForMainMenu.java | 29 + ..._ExtraTransform_CopyBetweenModelParts.java | 27 + .../renderer/MixinPlayerSkinTexture.java | 73 ++ .../entity/Mixin_DisableArmorRendering.java | 43 + .../entity/Mixin_DisableElytraRendering.java | 34 + .../entity/Mixin_RenderNameplateIcon.java | 50 + .../entity/Mixin_StoreArmorRenderedState.java | 46 + .../compatibility/sba/MixinTabLine.java | 20 + .../sba/MixinTabListRenderer.java | 20 + .../events/Mixin_GuiClickEvent.java | 57 + .../events/Mixin_GuiClickEvent_Priority.java | 51 + .../events/Mixin_GuiMouseReleaseEvent.java | 45 + .../events/Mixin_MouseScrollEvent.java | 52 + .../feature/chat/Mixin_ChatPeekScrolling.java | 44 + ..._ISoundExt_isRelativeToListener_apply.java | 19 + ...SoundExt_isRelativeToListener_forward.java | 19 + .../sound/Mixin_ISoundExt_maxDistance.java | 19 + .../LegacyV4AdapterPackMixin_Ext.java | 37 + .../server/GameRulesAccessor.java | 26 + .../server/GameRulesBooleanValueAccessor.java | 27 + ...ameRulesMixin_RegisterCustomGameRules.java | 19 + .../Mixin_ControlAreCommandsAllowed.java | 34 + .../Mixin_FixDefaultOpPermissionLevel.java | 26 + .../util/Mixin_CaptureScreenshots.java | 76 ++ .../util/math/Mixin_Matrix3fExt.java | 57 + .../util/math/Mixin_Matrix4fExt.java | 71 + .../brigadier/EssentialBrigadierArgument.kt | 43 + .../EssentialBrigadierIntegration.kt | 79 ++ .../brigadier/StringReaderArgumentQueue.kt | 47 + .../difficulty/ClientDifficultyState.kt | 13 + .../compatibility/vanilla/difficulty/Net.kt | 17 + .../vanilla/difficulty/UpdateDifficulty.kt | 13 + .../difficulty/UpdateDifficultyLock.kt | 13 + .../src/main/resources/pack.mcmeta | 6 + versions/1.17.1-1.16.2.txt | 13 + .../Mixin_CancelVanillaScreenshotHotkey.java | 41 + ...Mixin_FixPlayerInventoryLightingOn116.java | 19 + .../forge/Mixin_FixPrematureByteBufFree.java | 54 + ...FixArrowsStuckInUnusedPlayerModelPart.java | 45 + ...rkaroundBrokenFramebufferBlitBlending.java | 59 + .../skinmask/Mixin_ApplyToHandRenderer.java | 66 + .../forge/Mixin_FixInternalByteBufAccess.java | 41 + .../Mixin_InstallTrustingServicesKeyInfo.java | 30 + .../Mixin_IntegratedServerResourcePack.java | 47 + versions/1.19.3-1.19.2.txt | 8 + .../Mixin_FixServerDataNotReset_OnCancel.java | 22 + ...in_FixServerDataNotReset_OnDisconnect.java | 22 + .../sound/Mixin_LoadFromEssentialAsset.java | 57 + .../AbstractResourcePackMixin_Ext.java | 19 + .../DirectoryResourcePackMixin_Ext.java | 31 + .../resources/LegacyAdapterPackMixin_Ext.java | 20 + .../LegacyV4AdapterPackMixin_Ext.java | 20 + .../resources/ZipResourcePackMixin_Ext.java | 33 + .../util/math/Mixin_Matrix3fExt.java | 23 + .../util/math/Mixin_Matrix4fExt.java | 23 + ...xin_SkipLanScanningEntryForCustomTabs.java | 38 + .../events/Mixin_RenderTickEvent.java | 46 + .../MixinGuiInventory_UI3DPlayerOffset.java | 19 + ...vementDuringEmoteWheel_HandleKeybinds.java | 35 + ...owMovementDuringEmoteWheel_HandleKeys.java | 35 + ...yMenuScreenBackgroundRenderedEventAcc.java | 33 + ...inMenu_2_14_10_GuiDrawScreenEvent_Pre.java | 56 + versions/1.20.2-1.20.1.txt | 3 + .../resources/PlayerSkinProviderAccessor.java | 20 + .../Mixin_RegisterEssentialChannel.java | 66 + .../resources/Mixin_InstantSkinLoad.java | 41 + .../PlayerSkinProviderAccessorMixin.java | 62 + .../SkinProviderFileCacheAccessor.java | 28 + .../forge/Mixin_FixPrematureByteBufFree.java | 19 + .../resources/ZipFileWrapperAccessor.java | 23 + .../resources/ZipResourcePackMixin_Ext.java | 41 + versions/1.20.2-forge/gradle.properties | 1 + versions/1.20.4-1.20.2.txt | 2 + versions/1.20.4-fabric/gradle.properties | 2 + versions/1.20.4-forge/gradle.properties | 1 + .../handlers/EssentialChannelHandler.java | 32 + .../screenshot/bytebuf/WrappedByteBuf.java | 842 ++++++++++++ .../MixinCustomLayerBipedArmor.java | 45 + .../oldanimationsmod/MixinModelMethods.java | 57 + .../client/Mixin_EmoteWheelUnPressKeys.java | 50 + .../Mixin_ApplyPoseTransform_Elytra.java | 19 + ...n_ApplyPoseTransform_EntityOnShoulder.java | 19 + .../entity/Mixin_DisableElytraRendering.java | 19 + .../entity/Mixin_RenderNameplateIcon.java | 38 + .../Mixin_RenderNameplateIcon_Sneaking.java | 40 + .../Mixin_UnbindConflictingKeybinds.java | 19 + .../fancymenu/KonkreteDrawScreenEventAcc.java | 18 + .../fancymenu/KonkreteGuiScreenEventAcc.java | 18 + ...FancyMainMenu_GuiDrawScreenEvent_Post.java | 18 + ..._FancyMainMenu_GuiDrawScreenEvent_Pre.java | 18 + .../Mixin_FixBufferBuilderSortReset.java | 48 + .../vanilla/Mixin_FixResourcePackCrash.java | 48 + .../sound/Mixin_ISoundExt_createAccessor.java | 31 + .../resources/LegacyAdapterPackMixin_Ext.java | 19 + .../src/main/resources/pack.mcmeta | 6 + versions/aliases.txt | 4 + versions/api/1.20.2-forge/gradle.properties | 1 + versions/api/1.20.4-fabric/gradle.properties | 2 + versions/api/1.20.4-forge/gradle.properties | 1 + versions/api/mainProject | 1 + versions/mainProject | 1 + 1804 files changed, 146111 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .gitmodules delete mode 100644 .init create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api/api/api.api create mode 100644 api/build.gradle.kts create mode 100644 api/gradle.properties create mode 100644 api/root.gradle.kts create mode 100644 api/src/main/java/gg/essential/api/utils/JsonHolder.java create mode 100644 api/src/main/kotlin/gg/essential/api/DI.kt create mode 100644 api/src/main/kotlin/gg/essential/api/EssentialAPI.kt create mode 100644 api/src/main/kotlin/gg/essential/api/commands/ArgumentParser.kt create mode 100644 api/src/main/kotlin/gg/essential/api/commands/ArgumentQueue.kt create mode 100644 api/src/main/kotlin/gg/essential/api/commands/Command.kt create mode 100644 api/src/main/kotlin/gg/essential/api/commands/CommandRegistry.kt create mode 100644 api/src/main/kotlin/gg/essential/api/commands/annotations.kt create mode 100644 api/src/main/kotlin/gg/essential/api/config/EssentialConfig.kt create mode 100644 api/src/main/kotlin/gg/essential/api/cosmetics/RenderCosmetic.kt create mode 100644 api/src/main/kotlin/gg/essential/api/data/OnboardingData.kt create mode 100644 api/src/main/kotlin/gg/essential/api/gui/EssentialGUI.kt create mode 100644 api/src/main/kotlin/gg/essential/api/gui/GuiRequiresTOS.kt create mode 100644 api/src/main/kotlin/gg/essential/api/gui/Notifications.kt create mode 100644 api/src/main/kotlin/gg/essential/api/gui/essentialComponentFactory.kt create mode 100644 api/src/main/kotlin/gg/essential/api/profile/WrappedGameProfile.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/GuiUtil.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/KotlinAdapter.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/MinecraftUtils.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/Multithreading.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/SerialExecutor.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/ShutdownHookUtil.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/TrustedHostsUtil.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/WebUtil.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/di.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/mojang/MojangAPI.kt create mode 100644 api/src/main/kotlin/gg/essential/api/utils/mojang/data.kt create mode 100644 api/src/main/resources/fabric.mod.json create mode 100644 build-logic/build.gradle.kts create mode 100644 build-logic/settings.gradle.kts create mode 100644 build-logic/src/main/kotlin/essential/SyncToExternalRepoTask.kt create mode 100644 build-logic/src/main/kotlin/essential/dokka.kt create mode 100644 build-logic/src/main/kotlin/essential/embedded-loader.gradle.kts create mode 100644 build-logic/src/main/kotlin/essential/pinned-jar.gradle.kts create mode 100644 build-logic/src/main/kotlin/essential/preprocessor.kt create mode 100644 build-logic/src/main/kotlin/essential/repos.kt create mode 100644 build-logic/src/main/kotlin/essential/universal.kt create mode 100644 build-logic/src/main/kotlin/essential/utils.gradle.kts create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/BundlePlugin.kt create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/MixinPlugin.kt create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/RelocatePlugin.kt create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/compatmixin/CompatMixinTask.kt create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/util/KotlinVersion.kt create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/util/RelaxFabricLoaderDependencyTransform.kt create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/util/SlimKotlinForForgeTransform.kt create mode 100644 build-logic/src/main/kotlin/gg/essential/gradle/util/StripKotlinMetadataTransform.kt create mode 100644 build.gradle.kts create mode 100644 changelog/release-1.0.0.1.md create mode 100644 changelog/release-1.0.0.10.1.md create mode 100644 changelog/release-1.0.0.10.md create mode 100644 changelog/release-1.0.0.2.md create mode 100644 changelog/release-1.0.0.3.md create mode 100644 changelog/release-1.0.0.4.md create mode 100644 changelog/release-1.0.0.5.md create mode 100644 changelog/release-1.0.0.6.md create mode 100644 changelog/release-1.0.0.8.md create mode 100644 changelog/release-1.0.0.9.md create mode 100644 changelog/release-1.0.0.md create mode 100644 changelog/release-1.1.0.1.md create mode 100644 changelog/release-1.1.0.10.md create mode 100644 changelog/release-1.1.0.2.md create mode 100644 changelog/release-1.1.0.3.md create mode 100644 changelog/release-1.1.0.4.md create mode 100644 changelog/release-1.1.0.5.md create mode 100644 changelog/release-1.1.0.6.md create mode 100644 changelog/release-1.1.0.7.md create mode 100644 changelog/release-1.1.0.9.md create mode 100644 changelog/release-1.1.0.md create mode 100644 changelog/release-1.1.1.1.md create mode 100644 changelog/release-1.1.1.2.md create mode 100644 changelog/release-1.1.1.3.md create mode 100644 changelog/release-1.1.1.md create mode 100644 changelog/release-1.2.0.1.md create mode 100644 changelog/release-1.2.0.10.md create mode 100644 changelog/release-1.2.0.11.md create mode 100644 changelog/release-1.2.0.12.md create mode 100644 changelog/release-1.2.0.13.md create mode 100644 changelog/release-1.2.0.14.md create mode 100644 changelog/release-1.2.0.3.md create mode 100644 changelog/release-1.2.0.4.md create mode 100644 changelog/release-1.2.0.5.md create mode 100644 changelog/release-1.2.0.6.md create mode 100644 changelog/release-1.2.0.7.md create mode 100644 changelog/release-1.2.0.8.md create mode 100644 changelog/release-1.2.0.9.md create mode 100644 changelog/release-1.2.1.1.md create mode 100644 changelog/release-1.2.1.2.md create mode 100644 changelog/release-1.2.1.3.md create mode 100644 changelog/release-1.2.1.4.md create mode 100644 changelog/release-1.2.1.5.md create mode 100644 changelog/release-1.2.1.6.md create mode 100644 changelog/release-1.2.1.7.md create mode 100644 changelog/release-1.2.1.8.md create mode 100644 changelog/release-1.2.1.md create mode 100644 changelog/release-1.2.2.1.md create mode 100644 changelog/release-1.2.2.2.md create mode 100644 changelog/release-1.2.2.3.md create mode 100644 changelog/release-1.2.2.4.md create mode 100644 changelog/release-1.2.2.md create mode 100644 changelog/release-1.2.3.1.md create mode 100644 changelog/release-1.2.3.md create mode 100644 changelog/release-1.3.0.1.md create mode 100644 changelog/release-1.3.0.2.md create mode 100644 changelog/release-1.3.0.3.md create mode 100644 changelog/release-1.3.0.4.md create mode 100644 changelog/release-1.3.0.5.md create mode 100644 changelog/release-1.3.0.6.md create mode 100644 changelog/release-1.3.0.md create mode 100644 changelog/release-1.3.1.1.md create mode 100644 changelog/release-1.3.1.2.md create mode 100644 changelog/release-1.3.1.3.md create mode 100644 changelog/release-1.3.1.md create mode 100644 changelog/release-1.3.2.md create mode 100644 clipboard/build.gradle.kts create mode 100644 clipboard/src/main/kotlin/gg/essential/clipboard/AWTClipboard.kt create mode 100644 clipboard/src/main/kotlin/gg/essential/clipboard/Clipboard.kt create mode 100644 clipboard/src/main/kotlin/gg/essential/clipboard/MacOSClipboard.kt create mode 100644 cosmetics/build.gradle.kts create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/CosmeticsState.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/EquippedCosmetic.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/WearablesManager.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipper.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipperImpl.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEvent.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEventType.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationTarget.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinMaskConfig.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinPartSetting.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/EssentialAnimationSystem.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/TextureAnimationSync.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/WearableLocator.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/types.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/EssentialAsset.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/Model.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/Skin.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/asset/AssetProvider.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CapeModel.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticAssets.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticBundle.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticCategory.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticOutfit.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticSlot.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticTier.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticType.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticsSubject.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/EmoteWheelPage.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/OutfitSkin.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/PlayerModel.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/SkinLayer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/capeDisabled.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/CosmeticsDatabase.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItem.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItemType.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPage.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPageCollection.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/preview/PerspectiveCamera.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticProperty.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticPropertyType.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSetting.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettingType.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettings.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/FallbackPolymorphicSerializer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/UntypedCosmeticSetting.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/Animation.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/BedrockModel.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/Bone.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/Box3.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/Cube.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/CubeUvData.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/EnumPart.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/Face.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/ModelInstance.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/ModelParser.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleEffect.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleSystem.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/PositionTexVertex.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/RenderMetadata.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/Side.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/SoundCategory.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/SoundEffect.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/Vector3.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/backend/PlayerPose.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/backend/RenderBackend.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/collision/CollisionProvider.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/collision/PlaneCollisionProvider.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/file/AnimationFile.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/file/ModelFile.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/file/ParticlesFile.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/file/SoundDefinitionsFile.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/light/Light.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/light/LightProvider.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangContext.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangExpression.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangVec3.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/AnySerializer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/ColorAsRgbSerializer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/Instant.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsIso8601Serializer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsMillisSerializer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/ListOrSingleSerializer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/PairAsListSerializer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/PlayerPoseManager.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/TreeMap.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/UMatrixStack.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/UVertexConsumer.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/kotgl.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/platformMath.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/model/util/quaternion.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/network/cosmetics/Cosmetic.kt create mode 100644 cosmetics/src/commonMain/kotlin/gg/essential/serialization/SnakeAsUpperCaseSerializer.kt create mode 100644 cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/Instant.kt create mode 100644 cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/platformMath.kt create mode 100644 elementa/build.gradle.kts create mode 100644 elementa/layoutdsl/build.gradle.kts create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/HollowUIContainer.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/ListState.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/Spacer.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/AlternateConstraint.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/CenterPixelConstraint.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/FillConstraintIncludingPadding.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/SpacedCramSiblingConstraint.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/stateExtensions.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/README.md create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/alignment.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/arrangement.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/axis.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/basicModifiers.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/color.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/containers.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/events.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/float.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/layout.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/lazy.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/modifier.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/size.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/state.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/util.kt create mode 100644 elementa/layoutdsl/src/main/kotlin/gg/essential/gui/util/elementaExtensions.kt create mode 100644 elementa/statev2/build.gradle.kts create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedList.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedSet.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedList.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedSet.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/utils.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/color/color.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/booleans.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/pair.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/state.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/strings.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/utils.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/compatibility.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/coroutine.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/flatten.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/Impl.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/basic/impl.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/legacy/impl.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/minimal/impl.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/list.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/listCombinators.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/set.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/setCombinators.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/state.kt create mode 100644 elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/stateBy.kt create mode 100644 feature-flags/build.gradle.kts create mode 100644 feature-flags/src/main/java/gg/essential/config/AccessedViaReflection.java create mode 100644 feature-flags/src/main/java/gg/essential/config/FeatureFlags.java create mode 100644 feature-flags/src/main/java/gg/essential/config/LoadsResources.java create mode 100644 feature-flags/src/main/kotlin/gg/essential/config/HideIfDisabled.kt create mode 100644 features.properties create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 gui/elementa/build.gradle.kts create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/common/WeakState.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/common/extensions.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/GuiScaleOffsetConstraint.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/LazyConstraint.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/PredicatedHitTestContainer.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/AlphaEffect.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/GradientEffect.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/ZIndexEffect.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/DrawState.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/EssentialMarkdown.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownConfig.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownRenderer.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/BlockquoteDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/Drawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/DrawableList.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HardBreakDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HeaderDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ImageDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ListDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ParagraphDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/SoftBreakDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/TextDrawable.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttribute.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttributeExtension.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/internal/ColorAttributeDelimiterProcessor.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Cursor.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/ImageCursor.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Selection.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/TextCursor.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/elementa/transitions/FadeInTransition.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/gui/layoutdsl/gradient.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/util/guiElementaPlatform.kt create mode 100644 gui/elementa/src/main/kotlin/gg/essential/util/strings.kt create mode 100644 gui/essential/build.gradle.kts create mode 100644 gui/essential/src/main/java/gg/essential/handlers/CertChain.java create mode 100644 gui/essential/src/main/java/gg/essential/util/APIException.java create mode 100644 gui/essential/src/main/java/gg/essential/util/PlayerNotFoundException.java create mode 100644 gui/essential/src/main/java/gg/essential/util/RateLimitException.java create mode 100644 gui/essential/src/main/java/gg/essential/util/UuidNameLookup.java create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/EssentialPalette.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/AutoImageSize.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/Checkbox.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/CompactEssentialToggle.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/ContextOptionMenu.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialCollapsibleSearchbar.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialDropDown.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialSlider.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/FadeEffect.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/HighlightedBlock.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/HoverableInfoBlock.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/IconButton.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/ImageLoadCallback.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/LoadingIcon.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/MenuButton.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/OldEssentialDropDown.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/ScrollSpacer.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/SequenceAnimatedUIImage.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/Tooltips.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/constraints/ColumnPositionConstraint.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/constraints/DivisionConstraint.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/constraints/MarkdownContentWidthConstraint.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/constraints/MultiplicativeConstraint.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/effect/AdvancedScissorEffect.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/input/AbstractTextInput.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/input/StateTextInput.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/input/UIMultilineTextInput.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/input/UITextInput.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/input/essentialInput.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/CancelableInputModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/ConfirmDenyModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/DangerConfirmationEssentialModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/Modal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/NoticeEssentialModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/PropertiesModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/SearchableConfirmDenyModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/UsernameInputModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/modal/VerticalConfirmDenyModal.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/outline/GuiScaleOffsetOutline.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/shadow/EssentialText.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/shadow/ShadowEffect.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/common/shadow/ShadowIcon.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/image/ImageFactory.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/image/ResourceImageFactory.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/layoutdsl/button.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/layoutdsl/checkbox.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/layoutdsl/effects.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/layoutdsl/icon.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/layoutdsl/text.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/layoutdsl/tooltips.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/gui/layoutdsl/wrappedText.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/ButtonTextureProvider.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/CachedAvatarImage.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/HttpUtils.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/ServerType.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/USession.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/dispatchers.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/essentialGuiExtensions.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/guiEssentialPlatform.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/image/bitmap/Bitmap.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/image/bitmap/extensions.kt create mode 100644 gui/essential/src/main/kotlin/gg/essential/util/image/bitmap/impl/IntArrayBitmap.kt create mode 100644 gui/vigilance/build.gradle.kts create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/builder/StateBackedPropertyValue.kt create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/builder/VisibleDependencyPredicate.kt create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/components/settings.kt create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/components/subcategory.kt create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/data.kt create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/layout.kt create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/palette/VigilancePalette.kt create mode 100644 gui/vigilance/src/main/kotlin/gg/essential/gui/vigilancev2/utils/propertyData.kt create mode 100644 infra/build.gradle.kts create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/enums/ChannelType.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/enums/ReportVerdict.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/model/Channel.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/model/ChannelSettings.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/model/ClosedInfo.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/model/CreatedInfo.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/model/Message.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/model/MessageReport.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/chat/model/ReportVerdict.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/relationships/enums/FriendRequestPrivacySetting.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/relationships/enums/RelationshipState.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/relationships/enums/RelationshipType.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/relationships/serialisation/FriendRequestPrivacySettingTypeAdapter.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/relationships/serialisation/RelationshipStateAdapter.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/relationships/serialisation/RelationshipTypeAdapter.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/serialization/DateTimeTypeAdapter.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/serialization/UUIDTypeAdapter.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/util/DateTime.java create mode 100644 infra/src/main/java/com/sparkuniverse/toolbox/util/Validate.java create mode 100644 infra/src/main/java/gg/essential/coins/model/CoinBundle.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/enums/ActivityType.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/enums/ProfileStatus.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/model/relationships/Relationship.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/Packet.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ChatChannelMemberAddPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ChatChannelMemberRemovePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ChatChannelMessageDeletePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ChatChannelUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ClientChatChannelCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ClientChatChannelMessageCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ClientChatChannelMessageReadStatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ClientChatChannelMessageReportPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ClientChatChannelMessageUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ClientChatChannelMessagesRetrievePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ClientChatChannelMutePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ServerChannelMemberActionResponsePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ServerChatChannelAddPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ServerChatChannelClearPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ServerChatChannelMessagePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ServerChatChannelMessageReportPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ServerChatChannelMessageReportReasonsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/chat/ServerChatChannelRemovePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ClientCheckoutClaimCoinsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ClientCheckoutCoinBundlePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ClientCheckoutCosmeticsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ClientCheckoutDynamicCoinBundlePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ClientCheckoutPartnerCodeRequestDataPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ClientCheckoutStoreBundlePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ServerCheckoutClaimCoinsResponsePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ServerCheckoutPartnerCodeDataPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/checkout/ServerCheckoutUrlPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/coins/ClientCoinBundleOptionsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/coins/ClientCoinsBalancePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/coins/ServerCoinBundleOptionsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/coins/ServerCoinsBalancePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/connection/ClientConnectionDisconnectPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/connection/ConnectionKeepAlivePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/connection/ConnectionRegisterPacketTypeIdPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/connection/ServerConnectionReconnectPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ClientCosmeticAnimationTriggerPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ClientCosmeticBulkRequestUnlockStatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ClientCosmeticCheckoutPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ClientCosmeticRequestPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ClientCosmeticsUserEquippedVisibilityTogglePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticAnimationTriggerPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticPlayerSettingsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticTypesPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticsPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticsRevokePurchasePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticsSkinTexturePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticsUserEquippedPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticsUserEquippedVisibilityPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/ServerCosmeticsUserUnlockedPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/capes/ClientCosmeticCapesUnlockedPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/categories/ClientCosmeticCategoriesRequestPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/categories/ServerCosmeticCategoriesPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/emote/ClientCosmeticEmoteWheelSelectPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/emote/ClientCosmeticEmoteWheelUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/emote/ServerCosmeticEmoteWheelPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitCosmeticSettingsUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitDeletePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitEquippedCosmeticsUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitNameUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitSelectPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitSelectedRequestPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitSkinUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ClientCosmeticOutfitUpdateFavoriteStatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ServerCosmeticOutfitPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/cosmetic/outfit/ServerCosmeticOutfitSelectedResponsePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/currency/ClientCurrencyOptionsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/currency/ServerCurrencyOptionsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/ice/IceCandidatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/ice/IceSessionPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/media/ClientMediaCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/media/ClientMediaDeleteRequestPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/media/ClientMediaGetUploadUrlPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/media/ClientMediaRequestPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/media/ClientMediaUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/media/ServerMediaPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/media/ServerMediaUploadUrlPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/mod/ClientModsAnnouncePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/multiplayer/ServerMultiplayerJoinServerPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/notices/ClientNoticeDismissPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/notices/ClientNoticeRequestPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/notices/ServerNoticePopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/notices/ServerNoticeRemovePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/pingproxy/ClientPingProxyPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/pingproxy/ServerPingProxyResponsePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/ClientProfileActivityPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/ServerProfileActivityPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/ServerProfileStatusPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/trustedhosts/ClientProfileTrustedHostsCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/trustedhosts/ClientProfileTrustedHostsDeletePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/trustedhosts/ServerProfileTrustedHostsClearPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/trustedhosts/ServerProfileTrustedHostsPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/profile/trustedhosts/ServerProfileTrustedHostsRemovePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/relationships/ClientRelationshipCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/relationships/RelationshipDeletePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/relationships/ServerRelationshipCreateFailedResponsePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/relationships/ServerRelationshipDeletePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/relationships/ServerRelationshipPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/relationships/ServerUuidNameMapPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/relationships/privacy/FriendRequestPrivacySettingPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/response/ResponseActionPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/serverdiscovery/ClientServerDiscoveryRequestPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/serverdiscovery/ServerServerDiscoveryPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/skin/ClientSkinCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/skin/ClientSkinDeletePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/skin/ClientSkinUpdateDataPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/skin/ClientSkinUpdateFavoriteStatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/skin/ClientSkinUpdateLastUsedStatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/skin/ClientSkinUpdateNamePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/skin/ServerSkinPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/social/ClientSocialDiscordRequestJoinServerResponsePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/social/SocialDiscordRequestJoinServerPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/social/SocialInviteToServerCancelPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/social/SocialInviteToServerPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/subscription/SubscriptionUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/telemetry/ClientTelemetryPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ClientUPnPSessionClosePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ClientUPnPSessionCreatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ClientUPnPSessionInvitesAddPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ClientUPnPSessionInvitesRemovePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ClientUPnPSessionPingProxyUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ClientUPnPSessionUpdatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ServerUPnPSessionInviteAddPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ServerUPnPSessionPopulatePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/upnp/ServerUPnPSessionRemovePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/wardrobe/ClientWardrobeSettingsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/wardrobe/ClientWardrobeStoreBundleRequestPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/wardrobe/ServerWardrobeSettingsPacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/packet/wardrobe/ServerWardrobeStoreBundlePacket.java create mode 100644 infra/src/main/java/gg/essential/connectionmanager/common/util/LoginUtil.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/CosmeticSlot.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/SkinLayer.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/holder/PriceHolder.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/holder/SkinLayersHolder.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/Cosmetic.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticAssets.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticCategory.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticGeometry.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticOutfit.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticSetting.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticSkinMask.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticStoreBundle.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticStoreBundleSkin.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticTier.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticType.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/CosmeticUnlockData.java create mode 100644 infra/src/main/java/gg/essential/cosmetics/model/EmoteWheel.java create mode 100644 infra/src/main/java/gg/essential/holder/DisplayNameHolder.java create mode 100644 infra/src/main/java/gg/essential/media/model/Media.java create mode 100644 infra/src/main/java/gg/essential/media/model/MediaLocationMetadata.java create mode 100644 infra/src/main/java/gg/essential/media/model/MediaLocationType.java create mode 100644 infra/src/main/java/gg/essential/media/model/MediaMetadata.java create mode 100644 infra/src/main/java/gg/essential/media/model/MediaVariant.java create mode 100644 infra/src/main/java/gg/essential/model/EssentialAsset.java create mode 100644 infra/src/main/java/gg/essential/notices/NoticeType.java create mode 100644 infra/src/main/java/gg/essential/notices/model/Notice.java create mode 100644 infra/src/main/java/gg/essential/profiles/model/TrustedHost.java create mode 100644 infra/src/main/java/gg/essential/serverdiscovery/model/ServerDiscovery.java create mode 100644 infra/src/main/java/gg/essential/serverdiscovery/model/ServerDiscoveryAssets.java create mode 100644 infra/src/main/java/gg/essential/skins/SkinModel.java create mode 100644 infra/src/main/java/gg/essential/skins/model/Skin.java create mode 100644 infra/src/main/java/gg/essential/upnp/UPnPPrivacy.java create mode 100644 infra/src/main/java/gg/essential/upnp/model/UPnPSession.java create mode 100644 kdiscordipc/build.gradle.kts create mode 100644 kdiscordipc/src/main/java/gg/essential/util/kdiscordipc/IsolatedClassLoader.java create mode 100644 kdiscordipc/src/main/java/gg/essential/util/kdiscordipc/RelaunchClassLoader.java create mode 100644 kdiscordipc/src/main/kotlin/gg/essential/util/kdiscordipc/KDiscordIPCLoader.kt create mode 100644 libs/build.gradle.kts create mode 160000 loader create mode 100644 lwjgl3/README.md create mode 100644 lwjgl3/build.gradle.kts create mode 100644 lwjgl3/impl/build.gradle.kts create mode 100644 lwjgl3/impl/src/main/kotlin/gg/essential/util/lwjgl3/impl/Bootstrap.kt create mode 100644 lwjgl3/impl/src/main/kotlin/gg/essential/util/lwjgl3/impl/GLBridge.kt create mode 100644 lwjgl3/impl/src/main/kotlin/gg/essential/util/lwjgl3/impl/NativeImageReaderImpl.kt create mode 100644 lwjgl3/impl/src/main/kotlin/gg/essential/util/lwjgl3/impl/TinyFdImpl.kt create mode 100644 lwjgl3/impl/src/main/kotlin/gg/essential/util/lwjgl3/impl/nanovg/NanoVGImpl.kt create mode 100644 lwjgl3/src/main/java/gg/essential/util/lwjgl3/IsolatedClassLoader.java create mode 100644 lwjgl3/src/main/java/gg/essential/util/lwjgl3/RelaunchClassLoader.java create mode 100644 lwjgl3/src/main/kotlin/gg/essential/util/lwjgl3/Lwjgl3Loader.kt create mode 100644 lwjgl3/src/main/kotlin/gg/essential/util/lwjgl3/api/NativeImageReader.kt create mode 100644 lwjgl3/src/main/kotlin/gg/essential/util/lwjgl3/api/TinyFd.kt create mode 100644 lwjgl3/src/main/kotlin/gg/essential/util/lwjgl3/api/nanovg/NanoVG.kt create mode 100644 lwjgl3/src/main/kotlin/gg/essential/util/lwjgl3/asm/GLBridgeTransformer.kt create mode 100644 mixin-compat/build.gradle.kts create mode 100644 mixin-compat/src/main/java/gg/essential/CompatAccessTransformer.java create mode 100644 mixin-compat/src/main/java/gg/essential/CompatMixin.java create mode 100644 mixin-compat/src/main/java/gg/essential/CompatShadow.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/BundledAsmTransformer.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/CallbackInjectorCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/GlobalPropertiesCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/LocalVariableDiscriminatorContextCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/LocalsCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/MixinConfigCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/MixinPlatformManagerCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/MixinProcessorCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/MixinServiceLaunchWrapperCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/MixinTransformerCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/ModifyVariableInjectorCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/TargetSelectorCompat.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/extensions/MixinConfigExt.java create mode 100644 mixin-compat/src/main/java/gg/essential/mixincompat/util/MixinCompatUtils.java create mode 100644 plasmo/build.gradle.kts create mode 100644 plasmo/src/main/java/gg/essential/compat/PlasmoVoiceCompat.java create mode 100644 quic-connector/build.gradle.kts create mode 100644 quic-connector/src/main/java/gg/essential/quic/LogOnce.java create mode 100644 quic-connector/src/main/java/gg/essential/quic/LogOnceHandler.java create mode 100644 quic-connector/src/main/java/gg/essential/quic/ProxyHandler.java create mode 100644 quic-connector/src/main/java/gg/essential/quic/QuicClientConnector.java create mode 100644 quic-connector/src/main/java/gg/essential/quic/QuicConnector.java create mode 100644 quic-connector/src/main/java/gg/essential/quic/QuicServerConnector.java create mode 100644 root.gradle.kts create mode 100644 settings.gradle.kts create mode 100644 slf4j-to-log4j/README.md create mode 100644 slf4j-to-log4j/build.gradle.kts create mode 100644 slf4j-to-log4j/src/main/java/org/slf4j/impl/Log4jLogger.java create mode 100644 slf4j-to-log4j/src/main/java/org/slf4j/impl/Log4jLoggerFactory.java create mode 100644 slf4j-to-log4j/src/main/java/org/slf4j/impl/StaticLoggerBinder.java create mode 100644 slf4j-to-log4j/src/main/java/org/slf4j/impl/StaticMDCBinder.java create mode 100644 slf4j-to-log4j/src/main/java/org/slf4j/impl/StaticMarkerBinder.java create mode 100644 src/main/java/club/sk1er/mods/core/tweaker/ModCoreTweaker.java create mode 100644 src/main/java/gg/essential/Essential.java create mode 100644 src/main/java/gg/essential/api/tweaker/EssentialTweaker.java create mode 100644 src/main/java/gg/essential/asm/EssentialTransformer.java create mode 100644 src/main/java/gg/essential/asm/compat/PhosphorTransformer.java create mode 100644 src/main/java/gg/essential/asm/compat/betterfps/BetterFpsTransformerWrapper.java create mode 100644 src/main/java/gg/essential/asm/compat/betterfps/tweaker/BetterFpsWrappingTweaker.java create mode 100644 src/main/java/gg/essential/commands/impl/CommandConfig.java create mode 100644 src/main/java/gg/essential/commands/impl/CommandInviteFriends.java create mode 100644 src/main/java/gg/essential/commands/impl/CommandMcFriends.java create mode 100644 src/main/java/gg/essential/commands/impl/CommandMessage.java create mode 100644 src/main/java/gg/essential/cosmetics/EssentialModelRenderer.java create mode 100644 src/main/java/gg/essential/cosmetics/PlayerWearableManager.java create mode 100644 src/main/java/gg/essential/cosmetics/events/AnimationEffectHandler.java create mode 100644 src/main/java/gg/essential/cosmetics/skinmask/MaskedSkinProvider.java create mode 100644 src/main/java/gg/essential/cosmetics/skinmask/ModelMaskInstance.java create mode 100644 src/main/java/gg/essential/cosmetics/skinmask/SkinMasker.java create mode 100644 src/main/java/gg/essential/cosmetics/skinmask/SkinRegion.java create mode 100644 src/main/java/gg/essential/cosmetics/source/ConfigurableCosmeticsSource.java create mode 100644 src/main/java/gg/essential/cosmetics/source/CosmeticsSource.java create mode 100644 src/main/java/gg/essential/cosmetics/source/LiveCosmeticsSource.java create mode 100644 src/main/java/gg/essential/dev/DevelopmentTweaker.java create mode 100644 src/main/java/gg/essential/event/CancellableEvent.java create mode 100644 src/main/java/gg/essential/event/EventHandler.java create mode 100644 src/main/java/gg/essential/event/client/ClientTickEvent.java create mode 100644 src/main/java/gg/essential/event/client/GameShutDownEvent.java create mode 100644 src/main/java/gg/essential/event/client/InitializationEvent.java create mode 100644 src/main/java/gg/essential/event/client/PostInitializationEvent.java create mode 100644 src/main/java/gg/essential/event/client/PreInitializationEvent.java create mode 100644 src/main/java/gg/essential/event/client/ReAuthEvent.java create mode 100644 src/main/java/gg/essential/event/entity/PlayerTickEvent.java create mode 100644 src/main/java/gg/essential/event/essential/InitMainMenuEvent.java create mode 100644 src/main/java/gg/essential/event/essential/TosAcceptedEvent.java create mode 100644 src/main/java/gg/essential/event/gui/GuiClickEvent.java create mode 100644 src/main/java/gg/essential/event/gui/GuiDrawScreenEvent.java create mode 100644 src/main/java/gg/essential/event/gui/GuiKeyTypedEvent.java create mode 100644 src/main/java/gg/essential/event/gui/GuiMouseReleaseEvent.java create mode 100644 src/main/java/gg/essential/event/gui/GuiOpenEvent.java create mode 100644 src/main/java/gg/essential/event/gui/InitGuiEvent.java create mode 100644 src/main/java/gg/essential/event/gui/MouseScrollEvent.java create mode 100644 src/main/java/gg/essential/event/network/chat/ChatEvent.java create mode 100644 src/main/java/gg/essential/event/network/chat/SendChatMessageEvent.java create mode 100644 src/main/java/gg/essential/event/network/chat/SendCommandEvent.java create mode 100644 src/main/java/gg/essential/event/network/server/ServerJoinEvent.java create mode 100644 src/main/java/gg/essential/event/network/server/ServerLeaveEvent.java create mode 100644 src/main/java/gg/essential/event/network/server/SingleplayerJoinEvent.java create mode 100644 src/main/java/gg/essential/event/render/RenderTickEvent.java create mode 100644 src/main/java/gg/essential/event/sps/PlayerJoinSessionEvent.java create mode 100644 src/main/java/gg/essential/event/sps/PlayerLeaveSessionEvent.java create mode 100644 src/main/java/gg/essential/event/sps/SPSStartEvent.java create mode 100644 src/main/java/gg/essential/forge/EssentialForgeMod.java create mode 100644 src/main/java/gg/essential/gui/screenshot/bytebuf/WrappedByteBuf.java create mode 100644 src/main/java/gg/essential/handlers/EssentialChannelHandler.java create mode 100644 src/main/java/gg/essential/handlers/GameProfileManager.java create mode 100644 src/main/java/gg/essential/handlers/ModCompatBypass.java create mode 100644 src/main/java/gg/essential/handlers/MojangSkinManager.java create mode 100644 src/main/java/gg/essential/handlers/NetworkHook.java create mode 100644 src/main/java/gg/essential/handlers/NetworkSubscriptionStateHandler.java create mode 100644 src/main/java/gg/essential/handlers/OnlineIndicator.java create mode 100644 src/main/java/gg/essential/handlers/ReAuthChecker.java create mode 100644 src/main/java/gg/essential/handlers/RenderPlayerBypass.java create mode 100644 src/main/java/gg/essential/handlers/ServerStatusHandler.java create mode 100644 src/main/java/gg/essential/handlers/ShutdownHook.java create mode 100644 src/main/java/gg/essential/handlers/WindowedFullscreenHandler.java create mode 100644 src/main/java/gg/essential/handlers/ZoomHandler.java create mode 100644 src/main/java/gg/essential/image/PNGFile.kt create mode 100644 src/main/java/gg/essential/image/imagescaling/AdvancedResizeOp.java create mode 100644 src/main/java/gg/essential/image/imagescaling/BSplineFilter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/BellFilter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/BiCubicFilter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/BiCubicHighFreqResponse.java create mode 100644 src/main/java/gg/essential/image/imagescaling/BoxFilter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/DimensionConstrain.java create mode 100644 src/main/java/gg/essential/image/imagescaling/HermiteFilter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/Lanczos3Filter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/MitchellFilter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/ProgressListener.java create mode 100644 src/main/java/gg/essential/image/imagescaling/ResampleFilter.java create mode 100644 src/main/java/gg/essential/image/imagescaling/ResampleFilters.java create mode 100644 src/main/java/gg/essential/image/imagescaling/ResampleOp.java create mode 100644 src/main/java/gg/essential/image/imagescaling/TriangleFilter.java create mode 100644 src/main/java/gg/essential/key/EssentialKeybinding.java create mode 100644 src/main/java/gg/essential/key/EssentialKeybindingRegistry.java create mode 100644 src/main/java/gg/essential/main/Bootstrap.java create mode 100644 src/main/java/gg/essential/main/Main.java create mode 100644 src/main/java/gg/essential/mixins/DummyTarget.java create mode 100644 src/main/java/gg/essential/mixins/MixinErrorHandler.java create mode 100644 src/main/java/gg/essential/mixins/Plugin.java create mode 100644 src/main/java/gg/essential/mixins/impl/ClassHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/MinecraftExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/MinecraftHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/audio/ISoundExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/entity/AbstractClientPlayerExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/gui/EssentialPostScreenDrawHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/gui/GuiMainMenuHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/gui/GuiNewChatHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/gui/GuiScreenHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/model/ModelBipedExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/model/ModelBipedUtil.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/renderer/entity/ArmorRenderingUtil.java create mode 100644 src/main/java/gg/essential/mixins/impl/client/resources/PlayerSkinProviderAccessor.java create mode 100644 src/main/java/gg/essential/mixins/impl/entity/player/EntityPlayerHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/feature/ice/common/AgentExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/feature/ice/common/MergingDatagramSocketExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/feature/ice/common/StunStackExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/feature/ice/common/TricklingCandidateHarvester.java create mode 100644 src/main/java/gg/essential/mixins/impl/feature/ice/common/rtt/CandidatePairExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/feature/ice/common/rtt/StunServerTransactionExt.java create mode 100644 src/main/java/gg/essential/mixins/impl/forge/PlayerListHook.java create mode 100644 src/main/java/gg/essential/mixins/impl/util/math/Matrix3fExt.kt create mode 100644 src/main/java/gg/essential/mixins/impl/util/math/Matrix4fExt.kt create mode 100644 src/main/java/gg/essential/mixins/init/Mixin_MixIntoNonCoremods.java create mode 100644 src/main/java/gg/essential/mixins/injection/points/AfterInvokeInInit.java create mode 100644 src/main/java/gg/essential/mixins/injection/points/BeforeConstantInInit.java create mode 100644 src/main/java/gg/essential/mixins/injection/points/BeforeFieldAccessInInit.java create mode 100644 src/main/java/gg/essential/mixins/injection/points/BeforeInvokeInInit.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/Plugin.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/cmm/CMMGuiConfigAccessor.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/cmm/MixinCMMGuiCustom_Events.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/epicfightmod/Mixin_BypassEpicFightMod_EmulatedPlayers.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/minecraftcapes/Mixin_EmulatedPlayerCapes.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/minecraftcapes/Mixin_EmulatedPlayerEars.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/neat/MixinHealthBarRenderer.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/oldanimationsmod/MixinCustomLayerBipedArmor.java create mode 100644 src/main/java/gg/essential/mixins/modcompat/transformers/oldanimationsmod/MixinModelMethods.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/MixinMinecraft.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/Mixin_CancelVanillaScreenshotHotkey.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/Mixin_EmoteWheelUnPressKeys.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/Mixin_GuiKeyTypedEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/Mixin_RunEssentialTasks.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/Mixin_SuppressScreenshotBufferFlip.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/Mixin_ThreadTaskExecutor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/MouseHelperAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/commands/ClientPlayNetHandlerMixin_CommandRegistration.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/entity/MixinAbstractClientPlayer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/GuiMultiplayerAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/GuiScreenAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiChat.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiDisconnected_AddInvalidSessionRefreshGui.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiInventory_UI3DPlayerOffset.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiMainMenu.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiMultiplayer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiNewChat.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiPlayerTabOverlay.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiScreen.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinServerListEntryLanScan.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/MixinServerListEntryNormal.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_BypassModCompat_Gui.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_ChatPeek.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixEnterToConnectInCustomTabs.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixKeyBindConflictInEmoteWheel.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixKeybindConsumedInEmoteWheel.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixKeybindUnpressedInEmoteWheel.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixPlayerInventoryLightingOn116.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_PreventMovingOfServersInCustomTabs.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_RecalculateMenuScale.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_RemoveChatLimit.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_ScaleForMainMenu.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_SkipLanScanningEntryForCustomTabs.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_UI3DPlayer_FixNameTagRotation.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_UnfocusTextFieldWhileOverlayHasFocus.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/ServerListEntryNormalAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/ServerSelectionListAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/gui/inventory/Mixin_DisableCosmeticsInInventory.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/model/Mixin_ExtraTransform_CopyBetweenModelParts.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/model/ModelPlayerAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/model/geom/Mixin_ExtraTransform_ModelPart.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/multiplayer/MixinGuiConnecting_SetMostRecentlyConnectedMultiplayerServer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/multiplayer/MixinServerData.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/CPacketChatMessageAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/MixinServerPinger.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/Mixin_JoinEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/Mixin_LogExceptions.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/Mixin_RedirectToLocalConnection.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/Mixin_RefreshSkinOnChange.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/Mixin_RegisterEssentialChannel.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/Mixin_SendChatEvents.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/network/Mixin_TabListNameIdCache.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/options/GameOptionsAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/options/KeyBindingAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/options/MixinGameOptions.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/MixinEntityRenderer_Zoom.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/MixinPlayerSkinTexture.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/Mixin_HideHandWhenZoomed.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/MixinRender.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/MixinRenderPlayer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/MixinRendererLivingEntity.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_ApplyPoseTransform.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_ApplyPoseTransform_Cape.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_ApplyPoseTransform_Elytra.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_ApplyPoseTransform_EntityOnShoulder.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_CosmeticHoverOutline_Cape.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_DisableArmorRendering.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_DisableElytraRendering.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_DisablePlayerSkullRendering.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_DontRenderHitboxOnEmulatedPlayer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_RenderNameplateIcon.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_RenderNameplateIcon_Sneaking.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_StoreArmorRenderedState.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_WorkaroundIncorrectFrustumCheckAtHighSpeed.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/resources/Mixin_InstantSkinLoad.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/resources/PlayerSkinProviderAccessorMixin.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/resources/SkinProviderFileCacheAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/settings/MixinKeyBinding.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/settings/Mixin_UnbindConflictingKeybinds.java create mode 100644 src/main/java/gg/essential/mixins/transformers/client/shader/MixinFramebuffer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/FancyMenuScreenBackgroundRenderedEventAcc.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/KonkreteDrawScreenEventAcc.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/KonkreteGuiScreenEventAcc.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/Mixin_FancyMainMenu_2_14_10_GuiDrawScreenEvent_Pre.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/Mixin_FancyMainMenu_GuiDrawScreenEvent_Post.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/Mixin_FancyMainMenu_GuiDrawScreenEvent_Pre.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/forge/Mixin_FixInternalByteBufAccess.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/forge/Mixin_FixPrematureByteBufFree.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/labymod/MixinCustomMainMenu.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/labymod/MixinModGuiMultiplayer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/labymod/MixinModelCosmetics.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/labymod/MixinMultiplayerTabs.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/labymod/MixinRenderPlayerCustom.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/minecraftcapes/MixinPlayerHandler.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/notenoughanimations/AbstractWrapperAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/notenoughanimations/MixinArmTransformer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/notenoughanimations/MixinPlayerTransformer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/quark/Mixin_NukeModelReset.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/sba/MixinTabLine.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/sba/MixinTabListRenderer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/torohealth/Mixin_EmulatedPlayerCompat.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_FixArrowsStuckInUnusedPlayerModelPart.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_FixBufferBuilderSortReset.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_FixPacketHandlingPastServerShutdown.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_FixResourcePackCrash.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_WorkaroundBrokenFramebufferBlitBlending.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/serverdatareset/Mixin_FixServerDataNotReset_OnCancel.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/serverdatareset/Mixin_FixServerDataNotReset_OnDisconnect.java create mode 100644 src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/serverdatareset/package-info.java create mode 100644 src/main/java/gg/essential/mixins/transformers/cosmetics/capes/Mixin_RedirectVanillaCapeSetting.java create mode 100644 src/main/java/gg/essential/mixins/transformers/cosmetics/skinmask/Mixin_ApplyToHandRenderer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/cosmetics/skinmask/Mixin_ApplyToPlayerRenderer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/crash/MixinCrashReport.java create mode 100644 src/main/java/gg/essential/mixins/transformers/crash/MixinCrashReportCategory.java create mode 100644 src/main/java/gg/essential/mixins/transformers/entity/player/EntityPlayerAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/entity/player/MixinEntityPlayer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/events/Mixin_GuiClickEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/events/Mixin_GuiClickEvent_Priority.java create mode 100644 src/main/java/gg/essential/mixins/transformers/events/Mixin_GuiDrawScreenEvent_Priority.java create mode 100644 src/main/java/gg/essential/mixins/transformers/events/Mixin_GuiMouseReleaseEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/events/Mixin_MouseScrollEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/events/Mixin_RenderTickEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/chat/Mixin_ChatPeekScrolling.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/cosmetics/Mixin_UpdateOffscreenPlayers.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/difficulty/client/Mixin_TrackClientDifficultyChanges.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/difficulty/package-info.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/difficulty/server/Mixin_BroadcastServerDifficultyChanges.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/difficulty/server/Mixin_RemoveUnsafeDifficultyChecks.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/difficulty/server/Mixin_UseDifficultyFromLevel.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/emote/Mixin_AllowMovementDuringEmoteWheel_HandleKeybinds.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/emote/Mixin_AllowMovementDuringEmoteWheel_HandleKeys.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/gamerules/MixinGameRulesAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/gamerules/MixinGameRulesValueAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/client/Mixin_IceAddressResolving_Connect.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/client/Mixin_IceAddressResolving_Ping.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/Mixin_FixIce4JDroppingValidPairs.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/Mixin_FixIce4JKeepAliveScheduling.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/Mixin_FixIce4JTimeoutCancellation.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/Mixin_FixIce4JTurnTimeout.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/Mixin_IceChannelIsNotLocal.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/Mixin_WorkaroundWindowsUdpReset.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/IceMediaStreamAcc.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/Mixin_AgentExt.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/Mixin_CleanupUnusedRelayCandidates.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/Mixin_CloseRelayedAddressesFirst.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/Mixin_CountSentAndReceivedBytes.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/Mixin_FixIce4JDataRaceInStunRequestHandling.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/Mixin_FixIce4JLocalTrickling.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/Mixin_RememberRelayedAddresses.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/immediate_trickling/Mixin_AllowForImmediateCandidateTrickling.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/ice4j/immediate_trickling/Mixin_ImmediatelyTrickleStunCandidates.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/logging/Mixin_RedirectIce4jJulLoggers.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/rtt/Mixin_CandidatePairExt.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/rtt/Mixin_DecodeAttribute.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/rtt/Mixin_HandleAttributeOnClient.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/rtt/Mixin_HandleAttributeOnServer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/rtt/Mixin_StoreRequestInServerTransaction.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/rtt/Mixin_StoreRttInCandidatePair.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/common/rtt/Mixin_StunServerTransactionExt.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/ice/server/Mixin_NettyIceServer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/particles/Mixin_AddParticleSystemToClientWorld.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/particles/Mixin_PreserveRealYawDuringInventoryRendering.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/particles/Mixin_RenderParticleSystemOfClientWorld.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/per_server_privacy/Mixin_AddPerServerPrivacyButton.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/per_server_settings/Mixin_PreserveCustomServerData.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/skin_overwrites/Mixin_InstallTrustingServicesKeyInfo.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_createAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_isRelativeToListener_apply.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_isRelativeToListener_forward.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_maxDistance.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_LoadFromEssentialAsset.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sps/Mixin_IntegratedServerResourcePack.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sps/Mixin_PlayerJoinSessionEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sps/Mixin_PlayerLeaveSessionEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/feature/sps/Mixin_ResolveSpsResourcePackUrl.java create mode 100644 src/main/java/gg/essential/mixins/transformers/forge/MixinJarDiscoverer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/forge/MixinLoadController.java create mode 100644 src/main/java/gg/essential/mixins/transformers/forge/MixinNetworkModHolder.java create mode 100644 src/main/java/gg/essential/mixins/transformers/forge/Mixin_BypassModCompat_Check.java create mode 100644 src/main/java/gg/essential/mixins/transformers/forge/Mixin_BypassModCompat_Packet.java create mode 100644 src/main/java/gg/essential/mixins/transformers/forge/Mixin_FixRaceConditionInServerHandshake.java create mode 100644 src/main/java/gg/essential/mixins/transformers/forge/Mixin_PreventExtraPlayerLoadEvent.java create mode 100644 src/main/java/gg/essential/mixins/transformers/item/MixinElytraItem.java create mode 100644 src/main/java/gg/essential/mixins/transformers/resources/AbstractResourcePackMixin_Ext.java create mode 100644 src/main/java/gg/essential/mixins/transformers/resources/DirectoryResourcePackMixin_Ext.java create mode 100644 src/main/java/gg/essential/mixins/transformers/resources/LegacyAdapterPackMixin_Ext.java create mode 100644 src/main/java/gg/essential/mixins/transformers/resources/LegacyV4AdapterPackMixin_Ext.java create mode 100644 src/main/java/gg/essential/mixins/transformers/resources/MixinResourcePackList.java create mode 100644 src/main/java/gg/essential/mixins/transformers/resources/ZipFileWrapperAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/resources/ZipResourcePackMixin_Ext.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/GameRulesAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/GameRulesBooleanValueAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/GameRulesMixin_RegisterCustomGameRules.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/MinecraftServerMixin_PvPGameRule.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/Mixin_ControlAreCommandsAllowed.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/Mixin_InvertPlayerDataPriority.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/Mixin_PublishServerStatusResponse.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/integrated/LanConnectionsAccessor.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/integrated/MixinIntegratedServer.java create mode 100644 src/main/java/gg/essential/mixins/transformers/server/integrated/Mixin_FixDefaultOpPermissionLevel.java create mode 100644 src/main/java/gg/essential/mixins/transformers/util/MixinTabCompleter.java create mode 100644 src/main/java/gg/essential/mixins/transformers/util/Mixin_CaptureScreenshots.java create mode 100644 src/main/java/gg/essential/mixins/transformers/util/math/Mixin_Matrix3fExt.java create mode 100644 src/main/java/gg/essential/mixins/transformers/util/math/Mixin_Matrix4fExt.java create mode 100644 src/main/java/gg/essential/model/PlayerMolangQuery.java create mode 100644 src/main/java/gg/essential/network/client/MinecraftHook.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/AsyncResponseHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/CloseReason.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/Connection.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ConnectionCodec.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ConnectionManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/EarlyResponseHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/NetworkedManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/StateCallbackManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/UnknownPacket.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/chat/ChatManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/cosmetics/CosmeticsManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/cosmetics/PacketHandlers.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/PacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ChatChannelMemberAddPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ChatChannelMemberRemovePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ChatChannelMessageDeletePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ChatChannelUpdatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ServerChatChannelAddPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ServerChatChannelClearPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ServerChatChannelMessagePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ServerChatChannelMessageReportReasonsPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/chat/ServerChatChannelRemovePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/checkout/ServerCheckoutPartnerCodeDataPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/coins/ServerCoinsBalancePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/connection/ClientConnectionDisconnectPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/connection/ServerConnectionReconnectPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticAnimationTriggerPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticCategoriesPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticEmoteWheelPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticOutfitPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticOutfitSelectedResponsePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticSkinTexturePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticTypesPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticsPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticsRevokePurchasePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticsUserEquippedVisibilityPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerCosmeticsUserUnlockedPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/cosmetics/ServerWardrobeSettingsPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/mojang/ServerUuidNameMapPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/multiplayer/ServerMultiplayerJoinServerPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/profile/ServerProfileActivityPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/profile/ServerProfileStatusPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/profile/trustedhosts/ServerProfileTrustedHostsClearPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/profile/trustedhosts/ServerProfileTrustedHostsPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/profile/trustedhosts/ServerProfileTrustedHostsRemovePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/relationships/FriendRequestPrivacySettingPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/relationships/ServerRelationshipDeletePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/relationships/ServerRelationshipPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/screenshot/ServerScreenshotListPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/skins/ServerSkinPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/upnp/ServerUPnPSessionPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/upnp/ServerUPnPSessionRemovePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/handler/wardrobe/ServerWardrobeStoreBundlePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/IceManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/Log4jAsJulLogger.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/NominateBestRTT.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/handler/IceCandidatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/handler/IceSessionPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/netty/CloseAfterFirstMessage.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/netty/PseudoTcpChannelInitializer.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/netty/QuicStreamChannelInitializer.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/util/CandidateUtil.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/util/IWishMixinAllowedForPublicStaticFields.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/ice/util/StrictComparator.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/legacyjre/LegacyJre.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/legacyjre/LegacyJreDnsResolver.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/legacyjre/LegacyJreSocketFactory.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/media/ScreenshotCollectionChangeEvent.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/media/ScreenshotManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/media/ScreenshotUploadException.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/notices/NoticesManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/notices/handler/ServerNoticePopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/notices/handler/ServerNoticeRemovePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/profile/ProfileManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/queue/PacketQueue.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/queue/SequentialPacketQueue.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/relationship/FriendRequestState.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/relationship/RelationshipManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/relationship/RelationshipResponse.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/serverdiscovery/ServerDiscoveryManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/serverdiscovery/handler/ServerServerDiscoveryPopulatePacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/social/SocialManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/social/handler/SocialDiscordRequestJoinServerPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/social/handler/SocialInviteToServerCancelPacketHandler.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/sps/SPSConnectionTelemetry.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/sps/SPSManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/sps/SPSSessionSource.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/sps/SPSState.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/subscription/SubscriptionManager.java create mode 100644 src/main/java/gg/essential/network/connectionmanager/telemetry/TelemetryManager.java create mode 100644 src/main/java/gg/essential/util/ClientSideModUtil.java create mode 100644 src/main/java/gg/essential/util/Diamond.java create mode 100644 src/main/java/gg/essential/util/EssentialContainerUtil.java create mode 100644 src/main/java/gg/essential/util/JsonHolder.java create mode 100644 src/main/java/gg/essential/util/LoginUtil.java create mode 100644 src/main/java/gg/essential/util/MixinUtils.java create mode 100644 src/main/java/gg/essential/util/Multithreading.java create mode 100644 src/main/java/gg/essential/util/OptiFineUtil.java create mode 100644 src/main/java/gg/essential/util/ProtocolUtils.java create mode 100644 src/main/java/gg/essential/util/Sk1erModUtils.java create mode 100644 src/main/java/gg/essential/util/TemporaryFile.java create mode 100644 src/main/java/gg/essential/util/UUIDUtil.java create mode 100644 src/main/java/gg/essential/util/Version.java create mode 100644 src/main/java/gg/essential/util/WebUtil.java create mode 100644 src/main/java/gg/essential/util/crash/StacktraceDeobfuscator.java create mode 100644 src/main/java/gg/essential/util/swing/SwingUtil.java create mode 100644 src/main/java/gg/essential/util/swing/components/CircleButton.java create mode 100644 src/main/java/gg/essential/util/swing/components/RoundBoi.java create mode 100644 src/main/java/gg/essential/util/swing/components/TitleBar.java create mode 100644 src/main/java/org/ice4j/attribute/TransactionTransmitCounterAttribute.java create mode 100644 src/main/kotlin/gg/essential/DI.kt create mode 100644 src/main/kotlin/gg/essential/commands/EssentialCommandRegistry.kt create mode 100644 src/main/kotlin/gg/essential/commands/api/ArgumentQueueImpl.kt create mode 100644 src/main/kotlin/gg/essential/commands/api/WhitespaceSensitiveArgumentQueue.kt create mode 100644 src/main/kotlin/gg/essential/commands/brigadier/EssentialBrigadierArgument.kt create mode 100644 src/main/kotlin/gg/essential/commands/brigadier/EssentialBrigadierIntegration.kt create mode 100644 src/main/kotlin/gg/essential/commands/brigadier/StringReaderArgumentQueue.kt create mode 100644 src/main/kotlin/gg/essential/commands/engine/CommandParser.kt create mode 100644 src/main/kotlin/gg/essential/commands/engine/DefaultArgumentParsers.kt create mode 100644 src/main/kotlin/gg/essential/commands/impl/SPSHostCommands.kt create mode 100644 src/main/kotlin/gg/essential/compatibility/ModMenuCompat.kt create mode 100644 src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/ClientDifficultyState.kt create mode 100644 src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/Net.kt create mode 100644 src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/UpdateDifficulty.kt create mode 100644 src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/UpdateDifficultyLock.kt create mode 100644 src/main/kotlin/gg/essential/config/EssentialConfig.kt create mode 100644 src/main/kotlin/gg/essential/config/EssentialConfigApiImpl.kt create mode 100644 src/main/kotlin/gg/essential/config/GuiBuilder.kt create mode 100644 src/main/kotlin/gg/essential/config/GuiFactory.kt create mode 100644 src/main/kotlin/gg/essential/config/PassivePropertyCollector.kt create mode 100644 src/main/kotlin/gg/essential/config/Vigilant2.kt create mode 100644 src/main/kotlin/gg/essential/cosmetics/essentialModelRenderer.kt create mode 100644 src/main/kotlin/gg/essential/data/ABTestingData.kt create mode 100644 src/main/kotlin/gg/essential/data/MenuData.kt create mode 100644 src/main/kotlin/gg/essential/data/OnboardingData.kt create mode 100644 src/main/kotlin/gg/essential/data/SPSData.kt create mode 100644 src/main/kotlin/gg/essential/data/VersionData.kt create mode 100644 src/main/kotlin/gg/essential/gui/InternalEssentialGUI.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/AboutMenu.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/components/ChangelogComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/components/ColoredDivider.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/components/LeftPane.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/components/Page.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/components/RightPane.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/components/TopMenu.kt create mode 100644 src/main/kotlin/gg/essential/gui/about/components/VersionInfo.kt create mode 100644 src/main/kotlin/gg/essential/gui/account/MicrosoftUserAuthentication.kt create mode 100644 src/main/kotlin/gg/essential/gui/account/factory/ActiveSessionFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/account/factory/InitialSessionFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/account/factory/ManagedSessionFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/account/factory/MicrosoftAccountSessionFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/account/factory/SessionFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/api/ComponentFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/CosmeticHoverOutlineEffect.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/CosmeticPreview.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/CosmeticRenderPreview.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/CosmeticToasts.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/EmulatedUI3DPlayer.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/NoticeFlag.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/UI3DPlayer.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/UIFrameBuffer.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/UINanoVG.kt create mode 100644 src/main/kotlin/gg/essential/gui/common/modal/OpenLinkModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/elementa/VanillaButtonConstraint.kt create mode 100644 src/main/kotlin/gg/essential/gui/elementa/VanillaButtonGroupConstraint.kt create mode 100644 src/main/kotlin/gg/essential/gui/emotes/EmoteEventListeners.kt create mode 100644 src/main/kotlin/gg/essential/gui/emotes/EmoteWheel.kt create mode 100644 src/main/kotlin/gg/essential/gui/emotes/EmoteWheelEntry.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/SocialMenu.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/TabsSelector.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/MessageInput.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/MessageScreen.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/MessageTitleBar.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/MessageUtils.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/OldMessageInput.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/ReportMessageModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/RemoveableScreenshotPreview.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/ScreenshotAttacher.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/ScreenshotAttachmentComponents.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/ScreenshotAttachmentManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/ScreenshotItemsSlider.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/ScreenshotPicker.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/ScreenshotPreview.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/SelectableScreenshotPreview.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/screenshot/SimpleScreenshotProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/ClientMessage.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/DateDividerImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/GiftEmbedImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/ImageEmbedImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/InviteEmbedImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/MessageLine.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/MessageRef.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/MessageWrapperImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/ParagraphLineImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/ReplyableMessageInput.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/ReplyableMessageScreen.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/SkinEmbedImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/message/v2/UnreadDividerImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/modal/MakeGroupModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/BasicUserEntry.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/BlockedUserEntry.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/ChannelPreview.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/FriendStatus.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/FriendUserEntry.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/PendingUserEntry.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/SearchableItem.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/previews/SortListener.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/state/MessengerStateManagerImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/state/RelationshipStateManagerImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/state/SocialStateManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/state/SocialStates.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/state/StatusStateManagerImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/tabs/ChatTab.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/tabs/FriendsTab.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/tabs/TabComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/title/SocialTitleManagementActions.kt create mode 100644 src/main/kotlin/gg/essential/gui/friends/title/TitleManagementActions.kt create mode 100644 src/main/kotlin/gg/essential/gui/image/EssentialAssetImageFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/Account.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/AccountManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/LeftSideBar.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/RightSideBar.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/compact/CompactAccountSwitcher.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/compact/CompactRightSideBar.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/full/FullAccountSwitcher.kt create mode 100644 src/main/kotlin/gg/essential/gui/menu/full/FullRightSideBar.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/AccountNotValidModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/AddAccountModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/CosmeticsLoadingModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/DiscordActivityStatusModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/EssentialAutoInstalledModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/FeaturesEnabledModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/FirewallBlockingModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/NotAuthenticatedModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/TOSModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/UpdateAvailableModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/UpdateNotificationModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/select/SelectModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/select/builder.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/select/component/friend.kt create mode 100644 src/main/kotlin/gg/essential/gui/modals/select/component/group.kt create mode 100644 src/main/kotlin/gg/essential/gui/multiplayer/EssentialMultiplayerGui.kt create mode 100644 src/main/kotlin/gg/essential/gui/multiplayer/EssentialServerSelectionList.kt create mode 100644 src/main/kotlin/gg/essential/gui/multiplayer/FriendsIndicator.kt create mode 100644 src/main/kotlin/gg/essential/gui/multiplayer/disconnect/InvalidSessionRefreshGui.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/Notification.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/Notifications.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/content/ConfirmDenyNotificationActionComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/content/CosmeticPreviewToastComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/content/SkinPreviewToastComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/extensions.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/helpers.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/notificationLayout.kt create mode 100644 src/main/kotlin/gg/essential/gui/notification/toastButton.kt create mode 100644 src/main/kotlin/gg/essential/gui/notifications.kt create mode 100644 src/main/kotlin/gg/essential/gui/overlay/EphemeralLayer.kt create mode 100644 src/main/kotlin/gg/essential/gui/overlay/Layer.kt create mode 100644 src/main/kotlin/gg/essential/gui/overlay/LayerPriority.kt create mode 100644 src/main/kotlin/gg/essential/gui/overlay/OverlayManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/overlay/OverlayManagerImpl.kt create mode 100644 src/main/kotlin/gg/essential/gui/overlay/PersistentLayer.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/DateRange.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/ScreenshotId.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/ScreenshotOverlay.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/action/PostScreenshotAction.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/bytebuf/LimitedAllocator.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/bytebuf/WorkStealingAllocator.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/EditorToolbar.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/FocusComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/FocusEditComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/FocusListComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/InvalidScreenshotView.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ListViewComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotBrowser.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotComponents.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotDateGroup.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotItemsSlider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotOptionsDropdown.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotPreview.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotProviderManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotScrollComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotShareModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ScreenshotStateManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/components/ShareButton.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/concurrent/PrioritizedCallable.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/concurrent/PriorityThreadPoolExecutor.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/concurrent/ScreenshotWorkerThreadFactory.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/constraints/AspectPreservingFillConstraint.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/constraints/TileConstraint.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/downsampling/BufferBackedImage.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/downsampling/ErrorImage.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/downsampling/PixelBuffer.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/editor/ScreenshotCanvas.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/editor/change/Change.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/editor/change/CropChange.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/editor/change/VectorStroke.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/editor/tools/PenTool.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/editor/tools/Tool.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/handler/ScreenshotChecksumManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/handler/ScreenshotMetadataManager.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/image/ForkedImageClipboard.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/image/PixelBufferTexture.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/image/ScreenshotImage.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/CloudflareImageProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/DiskWindowedImageProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/FileCachedWindowedImageProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/MinecraftWindowedTextureProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/PostProcessWindowedImageProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/PriorityDelegatedWindowProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/ScopeExpansionWindowProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/ScopePreservingWindowedProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/ThreadedWindowedProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/TransitionWindowedProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/screenshot/providers/WindowProvider.kt create mode 100644 src/main/kotlin/gg/essential/gui/skin/skinUtils.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/InviteFriendsModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/WorldSelectionModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/WorldShareSettingsGui.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/categories/GameRulesCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/categories/PlayersAndPermissionsCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/categories/SPSSettingsCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/categories/WorldSettingsCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/sps/options/SpsOption.kt create mode 100644 src/main/kotlin/gg/essential/gui/state/Sale.kt create mode 100644 src/main/kotlin/gg/essential/gui/studio/Tag.kt create mode 100644 src/main/kotlin/gg/essential/gui/vigilancev2/VigilanceV2SettingsGui.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/EmoteWheelPage.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/Item.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/Wardrobe.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/WardrobeCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/WardrobeContainer.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/WardrobeState.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/categories/CategoryComponent.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/categories/categoryShared.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/categories/featuredCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/categories/outfitsCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/categories/skinsCategory.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/banner.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/coinPackImage.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/coinsButton.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/coinsText.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/cosmeticItem.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/cosmeticOrEmoteItemFunctions.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/cosmeticsGrid.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/gifting.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/infoIcon.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/noItemsFound.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/outfitComponents.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/outfitItemFunctions.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/previewWindow.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/sidebar.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/components/skinItemFunctions.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/AbstractConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/ConfigurationMenu.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/ConfigurationType.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/ConfigurationUtils.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/CosmeticBundleConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/CosmeticCategoryConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/CosmeticConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/CosmeticTypeConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/FeaturedPageCollectionConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/CosmeticCategoriesConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/ArmorHandlingConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/CosmeticBoneHidingConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/DefaultSidePropertyConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/ExternalHiddenBoneConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/InterruptsEmoteConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/LocalizationConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/PlayerPositionAdjustmentPropertyConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/PreviewResetTimeConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/RequiresUnlockActionConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/SingletonPropertyConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/TransitionDelayConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/properties/VariantsPropertyConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/settings/PlayerPositionAdjustmentSettingConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/settings/SideSettingConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/settings/SingletonSettingConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/configuration/cosmetic/settings/VariantSettingConfiguration.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/modals/CoinsPurchaseModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/modals/CoinsReceivedModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/modals/PurchaseConfirmModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/modals/SkinModal.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/purchase.kt create mode 100644 src/main/kotlin/gg/essential/gui/wardrobe/something/CosmeticGroup.kt create mode 100644 src/main/kotlin/gg/essential/handlers/EssentialGameRules.kt create mode 100644 src/main/kotlin/gg/essential/handlers/EssentialSoundManager.kt create mode 100644 src/main/kotlin/gg/essential/handlers/OptionsScreenOverlay.kt create mode 100644 src/main/kotlin/gg/essential/handlers/PauseMenuDisplay.kt create mode 100644 src/main/kotlin/gg/essential/handlers/WikiToastListener.kt create mode 100644 src/main/kotlin/gg/essential/handlers/account/WebAccountManager.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/DiscordIntegration.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/activity/ActivityState.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/activity/provider/ActivityStateProvider.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/activity/provider/impl/GameActivityStateProvider.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/activity/provider/impl/GuiActivityStateProvider.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/extensions/User.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/party/PartyInformation.kt create mode 100644 src/main/kotlin/gg/essential/handlers/discord/party/PartyManager.kt create mode 100644 src/main/kotlin/gg/essential/handlers/io/DirectoryWatcher.kt create mode 100644 src/main/kotlin/gg/essential/handlers/screenshot/ClientScreenshotMetadata.kt create mode 100644 src/main/kotlin/gg/essential/handlers/screenshot/FileSystemEvent.kt create mode 100644 src/main/kotlin/gg/essential/handlers/screenshot/ScreenshotUploadUtil.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/MinecraftExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/ParticleSystemHolder.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/gui/GuiMultiplayerExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/gui/GuiSlotExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/gui/LabyModMainMenu.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/gui/ServerListEntryNormalExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/model/geom/ExtraTransformHolder.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/multiplayer/ServerDataExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/network/NetHandlerPlayClientExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/renderer/PlayerSkinTextureExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/client/resource/FileResourcePackExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/compatibility/CMMGuiCustomExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/ext/network/NetworkSystemExt.kt create mode 100644 src/main/kotlin/gg/essential/mixins/transformers/compatibility/librarianlib/BitGridMixin.kt create mode 100644 src/main/kotlin/gg/essential/model/AnimatedCape.kt create mode 100644 src/main/kotlin/gg/essential/model/backend/minecraft/MinecraftRenderBackend.kt create mode 100644 src/main/kotlin/gg/essential/model/backend/minecraft/WorldCollisionProvider.kt create mode 100644 src/main/kotlin/gg/essential/model/backend/minecraft/WorldLightProvider.kt create mode 100644 src/main/kotlin/gg/essential/model/backend/minecraft/playerPose.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/ConnectionManagerKt.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/coins/CoinsManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/AssetLoader.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/CosmeticEquipVisibilityResponse.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/CosmeticsData.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/CosmeticsDataWithChanges.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/EquippedCosmeticsManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/InfraCosmeticsData.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/LocalCosmeticsData.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/ModelLoader.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/MutableCosmeticsData.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/OutfitManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/WardrobeSettings.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/connectionManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/localCosmeticBundleManagement.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/localCosmeticCategoryManagement.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/localCosmeticManagement.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/localCosmeticPropertyManagement.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/localCosmeticTypeManagement.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/cosmetics/localFeaturedPageCollectionManagement.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/handler/upnp/ServerUPnPSessionInviteAddPacketHandler.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/notices/FriendRequestToastNoticeListener.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/notices/GiftedCosmeticNoticeListener.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/notices/NoticeBannerManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/notices/PersistentToastNoticeListener.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/notices/SocialMenuNewFriendRequestNoticeManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/relationship/RelationshipErrorResponse.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/skins/SkinsManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/social/handler/SocialInviteToServerPacketHandler.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/telemetry/ImpressionTelemetryManager.kt create mode 100644 src/main/kotlin/gg/essential/network/connectionmanager/telemetry/telemetryManager.kt create mode 100644 src/main/kotlin/gg/essential/network/cosmetics/cape/CapeCosmeticsManager.kt create mode 100644 src/main/kotlin/gg/essential/network/cosmetics/cape/MojangCapeApi.kt create mode 100644 src/main/kotlin/gg/essential/network/cosmetics/conversions.kt create mode 100644 src/main/kotlin/gg/essential/network/pingproxy/ProxyPingServer.kt create mode 100644 src/main/kotlin/gg/essential/render/TextRenderTypeVertexConsumer.kt create mode 100644 src/main/kotlin/gg/essential/sps/FirewallUtil.kt create mode 100644 src/main/kotlin/gg/essential/sps/ResourcePackSharingHttpServer.kt create mode 100644 src/main/kotlin/gg/essential/sps/quic/QuicStream.kt create mode 100644 src/main/kotlin/gg/essential/sps/quic/jvm/ForkedJvmClientQuicStream.kt create mode 100644 src/main/kotlin/gg/essential/sps/quic/jvm/ForkedJvmQuicConnector.kt create mode 100644 src/main/kotlin/gg/essential/sps/quic/jvm/ForkedJvmServerQuicStreamPool.kt create mode 100644 src/main/kotlin/gg/essential/sps/quic/jvm/util.kt create mode 100644 src/main/kotlin/gg/essential/util/AddressUtil.kt create mode 100644 src/main/kotlin/gg/essential/util/AutoUpdate.kt create mode 100644 src/main/kotlin/gg/essential/util/ForkedJvm.kt create mode 100644 src/main/kotlin/gg/essential/util/GLUtil.kt create mode 100644 src/main/kotlin/gg/essential/util/GlFrameBuffer.kt create mode 100644 src/main/kotlin/gg/essential/util/GuiElementaPlatformImpl.kt create mode 100644 src/main/kotlin/gg/essential/util/GuiEssentialPlatformImpl.kt create mode 100644 src/main/kotlin/gg/essential/util/GuiUtil.kt create mode 100644 src/main/kotlin/gg/essential/util/LimitedExecutor.kt create mode 100644 src/main/kotlin/gg/essential/util/MinecraftCoroutineDispatchers.kt create mode 100644 src/main/kotlin/gg/essential/util/MinecraftUtils.kt create mode 100644 src/main/kotlin/gg/essential/util/ModLoaderUtil.kt create mode 100644 src/main/kotlin/gg/essential/util/MojangAPI.kt create mode 100644 src/main/kotlin/gg/essential/util/RefCounted.kt create mode 100644 src/main/kotlin/gg/essential/util/ResourceManagerUtil.kt create mode 100644 src/main/kotlin/gg/essential/util/ServerConnectionUtil.kt create mode 100644 src/main/kotlin/gg/essential/util/SimpleObservableMap.kt create mode 100644 src/main/kotlin/gg/essential/util/TargetThreadExecutor.kt create mode 100644 src/main/kotlin/gg/essential/util/TrustedHostsUtil.kt create mode 100644 src/main/kotlin/gg/essential/util/Try.kt create mode 100644 src/main/kotlin/gg/essential/util/currency.kt create mode 100644 src/main/kotlin/gg/essential/util/eventbus.kt create mode 100644 src/main/kotlin/gg/essential/util/eventbusCoroutineExtensions.kt create mode 100644 src/main/kotlin/gg/essential/util/extensions.kt create mode 100644 src/main/kotlin/gg/essential/util/helpers.kt create mode 100644 src/main/kotlin/gg/essential/util/iterable.kt create mode 100644 src/main/kotlin/gg/essential/util/model.kt create mode 100644 src/main/kotlin/gg/essential/util/resource/EssentialAssetResourcePack.kt create mode 100644 src/main/kotlin/gg/essential/util/resource/Lang3As2Pack.kt create mode 100644 src/main/kotlin/gg/essential/util/resource/Lang3As4Pack.kt create mode 100644 src/main/kotlin/gg/essential/util/resource/ResourcePackAdapter.kt create mode 100644 src/main/kotlin/gg/essential/util/skin.kt create mode 100644 src/main/kotlin/gg/essential/util/uIdentifier.kt create mode 100644 src/main/resources/META-INF/mods.toml create mode 100644 src/main/resources/assets/essential/account/core.c28d5d87.css create mode 100644 src/main/resources/assets/essential/account/error.html create mode 100644 src/main/resources/assets/essential/account/login/microsoft.html create mode 100644 src/main/resources/assets/essential/account/login/success.html create mode 100644 src/main/resources/assets/essential/account/main.5a1fd7db.js create mode 100644 src/main/resources/assets/essential/account/main.c766a760.js create mode 100644 src/main/resources/assets/essential/account/microsoft.2cc0fa02.js create mode 100644 src/main/resources/assets/essential/account/minecraft-computer-login.5074e926.png create mode 100644 src/main/resources/assets/essential/certs/amazon-root-ca-1.der create mode 100644 src/main/resources/assets/essential/certs/baltimore-cybertrust-root.der create mode 100644 src/main/resources/assets/essential/certs/d-trust-root-class-3-ca-2-2009.der create mode 100644 src/main/resources/assets/essential/certs/digicert-global-root-ca.der create mode 100644 src/main/resources/assets/essential/certs/digicert-global-root-g2.der create mode 100644 src/main/resources/assets/essential/certs/gts-root-r1.der create mode 100644 src/main/resources/assets/essential/certs/isrgrootx1.der create mode 100644 src/main/resources/assets/essential/certs/lets-encrypt-r3.der create mode 100644 src/main/resources/assets/essential/certs/microsoft-ecc-root-ca-2017.der create mode 100644 src/main/resources/assets/essential/certs/microsoft-rsa-root-ca-2017.der create mode 100644 src/main/resources/assets/essential/lang/en_us.lang create mode 100644 src/main/resources/assets/essential/sounds.json create mode 100644 src/main/resources/assets/essential/sounds/coin_fill_up.ogg create mode 100644 src/main/resources/assets/essential/sounds/purchase_confirmation.ogg create mode 100644 src/main/resources/assets/essential/sounds/screenshot.ogg create mode 100644 src/main/resources/assets/essential/textures/announcement_icon.png create mode 100644 src/main/resources/assets/essential/textures/arrow-left.png create mode 100644 src/main/resources/assets/essential/textures/arrow-left_5x7.png create mode 100644 src/main/resources/assets/essential/textures/arrow-right.png create mode 100644 src/main/resources/assets/essential/textures/arrow-right_3x5.png create mode 100644 src/main/resources/assets/essential/textures/arrow-up-right.png create mode 100644 src/main/resources/assets/essential/textures/cancel.png create mode 100644 src/main/resources/assets/essential/textures/cancel_10x10.png create mode 100644 src/main/resources/assets/essential/textures/cancel_12x12.png create mode 100644 src/main/resources/assets/essential/textures/cancel_5x5.png create mode 100644 src/main/resources/assets/essential/textures/cancel_7x7.png create mode 100644 src/main/resources/assets/essential/textures/cart_12x12.png create mode 100644 src/main/resources/assets/essential/textures/checkmark_7x5.png create mode 100644 src/main/resources/assets/essential/textures/checkmark_8x6.png create mode 100644 src/main/resources/assets/essential/textures/coin/coin_bundle_0_999.png create mode 100644 src/main/resources/assets/essential/textures/coin/coin_icon.png create mode 100644 src/main/resources/assets/essential/textures/cosmetics_off_10x7.png create mode 100644 src/main/resources/assets/essential/textures/download_7x8.png create mode 100644 src/main/resources/assets/essential/textures/dropdown/arrow_down.png create mode 100644 src/main/resources/assets/essential/textures/dropdown/arrow_up.png create mode 100644 src/main/resources/assets/essential/textures/ellipses_5x1.png create mode 100644 src/main/resources/assets/essential/textures/envelope_10x7.png create mode 100644 src/main/resources/assets/essential/textures/envelope_9x7.png create mode 100644 src/main/resources/assets/essential/textures/essential.png create mode 100644 src/main/resources/assets/essential/textures/filter.png create mode 100644 src/main/resources/assets/essential/textures/friends/burger.png create mode 100644 src/main/resources/assets/essential/textures/friends/group_blue.png create mode 100644 src/main/resources/assets/essential/textures/friends/group_green.png create mode 100644 src/main/resources/assets/essential/textures/friends/group_purple.png create mode 100644 src/main/resources/assets/essential/textures/friends/group_red.png create mode 100644 src/main/resources/assets/essential/textures/friends/group_yellow.png create mode 100644 src/main/resources/assets/essential/textures/friends/join_arrow.png create mode 100644 src/main/resources/assets/essential/textures/friends/make_group.png create mode 100644 src/main/resources/assets/essential/textures/friends/reply.png create mode 100644 src/main/resources/assets/essential/textures/friends/reply_left.png create mode 100644 src/main/resources/assets/essential/textures/heart.png create mode 100644 src/main/resources/assets/essential/textures/info_9x9.png create mode 100644 src/main/resources/assets/essential/textures/kick.png create mode 100644 src/main/resources/assets/essential/textures/link.png create mode 100644 src/main/resources/assets/essential/textures/lock_hollow_7x9.png create mode 100644 src/main/resources/assets/essential/textures/menu/arrow_down.png create mode 100644 src/main/resources/assets/essential/textures/menu/arrow_up.png create mode 100644 src/main/resources/assets/essential/textures/menu/bell.png create mode 100755 src/main/resources/assets/essential/textures/menu/cosmetics.png create mode 100644 src/main/resources/assets/essential/textures/menu/cosmetics_off.png create mode 100644 src/main/resources/assets/essential/textures/menu/cosmetics_on.png create mode 100755 src/main/resources/assets/essential/textures/menu/emotes.png create mode 100755 src/main/resources/assets/essential/textures/menu/essential.png create mode 100644 src/main/resources/assets/essential/textures/menu/expand.png create mode 100755 src/main/resources/assets/essential/textures/menu/friends.png create mode 100644 src/main/resources/assets/essential/textures/menu/fullscreen.png create mode 100644 src/main/resources/assets/essential/textures/menu/fullscreen_off.png create mode 100644 src/main/resources/assets/essential/textures/menu/fullscreen_on.png create mode 100644 src/main/resources/assets/essential/textures/menu/invite.png create mode 100644 src/main/resources/assets/essential/textures/menu/logo.png create mode 100644 src/main/resources/assets/essential/textures/menu/mc_folder.png create mode 100755 src/main/resources/assets/essential/textures/menu/messages.png create mode 100755 src/main/resources/assets/essential/textures/menu/mods.png create mode 100644 src/main/resources/assets/essential/textures/menu/notifications_off.png create mode 100644 src/main/resources/assets/essential/textures/menu/notifications_on.png create mode 100644 src/main/resources/assets/essential/textures/menu/pictures.png create mode 100644 src/main/resources/assets/essential/textures/menu/pictures_short.png create mode 100644 src/main/resources/assets/essential/textures/menu/radio_tick.png create mode 100644 src/main/resources/assets/essential/textures/menu/settings.png create mode 100644 src/main/resources/assets/essential/textures/menu/settings_9x8.png create mode 100644 src/main/resources/assets/essential/textures/menu/settings_vertical.png create mode 100644 src/main/resources/assets/essential/textures/menu/social.png create mode 100644 src/main/resources/assets/essential/textures/menu/star_4x3.png create mode 100644 src/main/resources/assets/essential/textures/menu/trash.png create mode 100644 src/main/resources/assets/essential/textures/menu/world_8x8.png create mode 100644 src/main/resources/assets/essential/textures/notice_11x11.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/block.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/block7x7.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/copy.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/cut.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/edit_short.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/folder.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/join_arrow.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/leave.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/link.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/mark_unread.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/message.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/mute.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/mute_new.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/paste.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/pencil.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/properties.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/remove_friend.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/remove_friend_player.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/rename.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/reply.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/report.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/social.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/unmute.png create mode 100644 src/main/resources/assets/essential/textures/optionmenu/unmute_new.png create mode 100644 src/main/resources/assets/essential/textures/pack_128x128.png create mode 100644 src/main/resources/assets/essential/textures/person_4x6.png create mode 100644 src/main/resources/assets/essential/textures/plus.png create mode 100644 src/main/resources/assets/essential/textures/plus_10x10.png create mode 100644 src/main/resources/assets/essential/textures/plus_5x5.png create mode 100644 src/main/resources/assets/essential/textures/plus_7x7.png create mode 100644 src/main/resources/assets/essential/textures/power-button_7x7.png create mode 100644 src/main/resources/assets/essential/textures/properties.png create mode 100644 src/main/resources/assets/essential/textures/retry_7x7.png create mode 100644 src/main/resources/assets/essential/textures/round_warning.png create mode 100644 src/main/resources/assets/essential/textures/sale_indicator.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/edit.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/favorite.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/file.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/folder_10x.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/folder_16x.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/link.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/copy.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/download.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/edit.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/folder.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/heart_filled.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/heart_outline.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/image_size_big.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/image_size_small.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/link.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/redo.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/save.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/undo.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/new/upload.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/options.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/trash_can_10x.png create mode 100644 src/main/resources/assets/essential/textures/screenshots/trash_can_16x.png create mode 100644 src/main/resources/assets/essential/textures/search.png create mode 100644 src/main/resources/assets/essential/textures/search_7x.png create mode 100644 src/main/resources/assets/essential/textures/sps/crown.png create mode 100644 src/main/resources/assets/essential/textures/sps/op.png create mode 100644 src/main/resources/assets/essential/textures/sps/pinned.png create mode 100644 src/main/resources/assets/essential/textures/sps/reinvite_5x5.png create mode 100644 src/main/resources/assets/essential/textures/sps/unpinned.png create mode 100644 src/main/resources/assets/essential/textures/studio/20_percent_sale.png create mode 100644 src/main/resources/assets/essential/textures/studio/add_to_cart.png create mode 100644 src/main/resources/assets/essential/textures/studio/animation_off.png create mode 100644 src/main/resources/assets/essential/textures/studio/animation_on.png create mode 100644 src/main/resources/assets/essential/textures/studio/cart.png create mode 100644 src/main/resources/assets/essential/textures/studio/cart_8x7.png create mode 100644 src/main/resources/assets/essential/textures/studio/clock_1.png create mode 100644 src/main/resources/assets/essential/textures/studio/clock_2.png create mode 100644 src/main/resources/assets/essential/textures/studio/clock_3.png create mode 100644 src/main/resources/assets/essential/textures/studio/clock_4.png create mode 100644 src/main/resources/assets/essential/textures/studio/featured.png create mode 100644 src/main/resources/assets/essential/textures/studio/gift.png create mode 100644 src/main/resources/assets/essential/textures/studio/grid.png create mode 100644 src/main/resources/assets/essential/textures/studio/hat.png create mode 100644 src/main/resources/assets/essential/textures/studio/heart_7x6.png create mode 100644 src/main/resources/assets/essential/textures/studio/lock.png create mode 100644 src/main/resources/assets/essential/textures/studio/lock_closed.png create mode 100644 src/main/resources/assets/essential/textures/studio/lock_open.png create mode 100644 src/main/resources/assets/essential/textures/studio/new_22x11.png create mode 100644 src/main/resources/assets/essential/textures/studio/partner.png create mode 100644 src/main/resources/assets/essential/textures/studio/partner_small.png create mode 100644 src/main/resources/assets/essential/textures/studio/play_arrow.png create mode 100644 src/main/resources/assets/essential/textures/studio/remove_from_cart.png create mode 100644 src/main/resources/assets/essential/textures/studio/rotate_left.png create mode 100644 src/main/resources/assets/essential/textures/studio/rotate_right.png create mode 100644 src/main/resources/assets/essential/textures/studio/row.png create mode 100644 src/main/resources/assets/essential/textures/studio/skin.png create mode 100644 src/main/resources/assets/essential/textures/studio/sliders.png create mode 100644 src/main/resources/assets/essential/textures/studio/upload-skin.png create mode 100644 src/main/resources/assets/essential/textures/toggle/off.png create mode 100644 src/main/resources/assets/essential/textures/toggle/on.png create mode 100644 src/main/resources/assets/essential/textures/turn_left_18x18.png create mode 100644 src/main/resources/assets/essential/textures/turn_right_18x18.png create mode 100644 src/main/resources/assets/essential/textures/wardrobe/character_4x6.png create mode 100644 src/main/resources/assets/essential/textures/wardrobe/emote_wheel.png create mode 100644 src/main/resources/assets/essential/textures/wardrobe/gift.png create mode 100644 src/main/resources/assets/essential/textures/wardrobe/star.png create mode 100644 src/main/resources/assets/essential/textures/white.png create mode 100644 src/main/resources/assets/essential/textures/wip.png create mode 100644 src/main/resources/assets/essential/version.txt create mode 100644 src/main/resources/fabric.mod.json create mode 100644 src/main/resources/mcmod.info create mode 100644 src/main/resources/mixins.essential.init.json create mode 100644 src/main/resources/mixins.essential.json create mode 100644 src/main/resources/mixins.essential.modcompat.json create mode 100644 src/main/resources/pack.mcmeta create mode 100644 utils/build.gradle.kts create mode 100644 utils/src/commonMain/kotlin/gg/essential/data/VersionInfo.kt create mode 100644 utils/src/commonMain/kotlin/gg/essential/model/util/Color.kt create mode 100644 utils/src/commonMain/kotlin/gg/essential/util/OperatingSystem.kt create mode 100644 utils/src/commonMain/kotlin/gg/essential/util/UIdentifier.kt create mode 100644 utils/src/jvmMain/kotlin/gg/essential/model/util/jvmColor.kt create mode 100644 versions/1.12.2-1.8.9.txt create mode 100644 versions/1.15.2-1.12.2.txt create mode 100644 versions/1.16.2-1.12.2.txt create mode 100644 versions/1.16.2-fabric/src/main/kotlin/gg/essential/compatibility/ModMenuCompat.kt create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/handlers/WindowedFullscreenHandler.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/init/Mixin_MixIntoNonCoremods.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/modcompat/transformers/cmm/MixinCMMGuiCustom_Events.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/modcompat/transformers/minecraftcapes/Mixin_EmulatedPlayerCapes.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/modcompat/transformers/minecraftcapes/Mixin_EmulatedPlayerEars.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/Mixin_CancelVanillaScreenshotHotkey.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/Mixin_GuiKeyTypedEvent.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/Mixin_SuppressScreenshotBufferFlip.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/Mixin_ThreadTaskExecutor.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/commands/ClientPlayNetHandlerMixin_CommandRegistration.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiInventory_UI3DPlayerOffset.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixKeybindConsumedInEmoteWheel.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixPlayerInventoryLightingOn116.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_PreventMovingOfServersInCustomTabs.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_RecalculateMenuScale.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_ScaleForMainMenu.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/model/Mixin_ExtraTransform_CopyBetweenModelParts.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/MixinPlayerSkinTexture.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_DisableArmorRendering.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_DisableElytraRendering.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_RenderNameplateIcon.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_StoreArmorRenderedState.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/compatibility/sba/MixinTabLine.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/compatibility/sba/MixinTabListRenderer.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/events/Mixin_GuiClickEvent.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/events/Mixin_GuiClickEvent_Priority.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/events/Mixin_GuiMouseReleaseEvent.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/events/Mixin_MouseScrollEvent.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/feature/chat/Mixin_ChatPeekScrolling.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_isRelativeToListener_apply.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_isRelativeToListener_forward.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_maxDistance.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/resources/LegacyV4AdapterPackMixin_Ext.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/server/GameRulesAccessor.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/server/GameRulesBooleanValueAccessor.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/server/GameRulesMixin_RegisterCustomGameRules.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/server/Mixin_ControlAreCommandsAllowed.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/server/integrated/Mixin_FixDefaultOpPermissionLevel.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/util/Mixin_CaptureScreenshots.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/util/math/Mixin_Matrix3fExt.java create mode 100644 versions/1.16.2-forge/src/main/java/gg/essential/mixins/transformers/util/math/Mixin_Matrix4fExt.java create mode 100644 versions/1.16.2-forge/src/main/kotlin/gg/essential/commands/brigadier/EssentialBrigadierArgument.kt create mode 100644 versions/1.16.2-forge/src/main/kotlin/gg/essential/commands/brigadier/EssentialBrigadierIntegration.kt create mode 100644 versions/1.16.2-forge/src/main/kotlin/gg/essential/commands/brigadier/StringReaderArgumentQueue.kt create mode 100644 versions/1.16.2-forge/src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/ClientDifficultyState.kt create mode 100644 versions/1.16.2-forge/src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/Net.kt create mode 100644 versions/1.16.2-forge/src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/UpdateDifficulty.kt create mode 100644 versions/1.16.2-forge/src/main/kotlin/gg/essential/compatibility/vanilla/difficulty/UpdateDifficultyLock.kt create mode 100644 versions/1.16.2-forge/src/main/resources/pack.mcmeta create mode 100644 versions/1.17.1-1.16.2.txt create mode 100644 versions/1.17.1-fabric/src/main/java/gg/essential/mixins/transformers/client/Mixin_CancelVanillaScreenshotHotkey.java create mode 100644 versions/1.17.1-fabric/src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_FixPlayerInventoryLightingOn116.java create mode 100644 versions/1.17.1-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/forge/Mixin_FixPrematureByteBufFree.java create mode 100644 versions/1.17.1-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_FixArrowsStuckInUnusedPlayerModelPart.java create mode 100644 versions/1.17.1-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_WorkaroundBrokenFramebufferBlitBlending.java create mode 100644 versions/1.17.1-fabric/src/main/java/gg/essential/mixins/transformers/cosmetics/skinmask/Mixin_ApplyToHandRenderer.java create mode 100644 versions/1.19-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/forge/Mixin_FixInternalByteBufAccess.java create mode 100644 versions/1.19-fabric/src/main/java/gg/essential/mixins/transformers/feature/skin_overwrites/Mixin_InstallTrustingServicesKeyInfo.java create mode 100644 versions/1.19-fabric/src/main/java/gg/essential/mixins/transformers/feature/sps/Mixin_IntegratedServerResourcePack.java create mode 100644 versions/1.19.3-1.19.2.txt create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/serverdatareset/Mixin_FixServerDataNotReset_OnCancel.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/serverdatareset/Mixin_FixServerDataNotReset_OnDisconnect.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_LoadFromEssentialAsset.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/resources/AbstractResourcePackMixin_Ext.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/resources/DirectoryResourcePackMixin_Ext.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/resources/LegacyAdapterPackMixin_Ext.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/resources/LegacyV4AdapterPackMixin_Ext.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/resources/ZipResourcePackMixin_Ext.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/util/math/Mixin_Matrix3fExt.java create mode 100644 versions/1.19.3-fabric/src/main/java/gg/essential/mixins/transformers/util/math/Mixin_Matrix4fExt.java create mode 100644 versions/1.19.4-fabric/src/main/java/gg/essential/mixins/transformers/client/gui/Mixin_SkipLanScanningEntryForCustomTabs.java create mode 100644 versions/1.19.4-fabric/src/main/java/gg/essential/mixins/transformers/events/Mixin_RenderTickEvent.java create mode 100644 versions/1.20-fabric/src/main/java/gg/essential/mixins/transformers/client/gui/MixinGuiInventory_UI3DPlayerOffset.java create mode 100644 versions/1.20-fabric/src/main/java/gg/essential/mixins/transformers/feature/emote/Mixin_AllowMovementDuringEmoteWheel_HandleKeybinds.java create mode 100644 versions/1.20-fabric/src/main/java/gg/essential/mixins/transformers/feature/emote/Mixin_AllowMovementDuringEmoteWheel_HandleKeys.java create mode 100644 versions/1.20.1-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/FancyMenuScreenBackgroundRenderedEventAcc.java create mode 100644 versions/1.20.1-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/Mixin_FancyMainMenu_2_14_10_GuiDrawScreenEvent_Pre.java create mode 100644 versions/1.20.2-1.20.1.txt create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/impl/client/resources/PlayerSkinProviderAccessor.java create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/transformers/client/network/Mixin_RegisterEssentialChannel.java create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/transformers/client/resources/Mixin_InstantSkinLoad.java create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/transformers/client/resources/PlayerSkinProviderAccessorMixin.java create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/transformers/client/resources/SkinProviderFileCacheAccessor.java create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/transformers/compatibility/forge/Mixin_FixPrematureByteBufFree.java create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/transformers/resources/ZipFileWrapperAccessor.java create mode 100644 versions/1.20.2-fabric/src/main/java/gg/essential/mixins/transformers/resources/ZipResourcePackMixin_Ext.java create mode 100644 versions/1.20.2-forge/gradle.properties create mode 100644 versions/1.20.4-1.20.2.txt create mode 100644 versions/1.20.4-fabric/gradle.properties create mode 100644 versions/1.20.4-forge/gradle.properties create mode 100644 versions/1.20.6-fabric/src/main/java/gg/essential/handlers/EssentialChannelHandler.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/gui/screenshot/bytebuf/WrappedByteBuf.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/modcompat/transformers/oldanimationsmod/MixinCustomLayerBipedArmor.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/modcompat/transformers/oldanimationsmod/MixinModelMethods.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/client/Mixin_EmoteWheelUnPressKeys.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_ApplyPoseTransform_Elytra.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_ApplyPoseTransform_EntityOnShoulder.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_DisableElytraRendering.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_RenderNameplateIcon.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/client/renderer/entity/Mixin_RenderNameplateIcon_Sneaking.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/client/settings/Mixin_UnbindConflictingKeybinds.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/KonkreteDrawScreenEventAcc.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/KonkreteGuiScreenEventAcc.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/Mixin_FancyMainMenu_GuiDrawScreenEvent_Post.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/compatibility/fancymenu/Mixin_FancyMainMenu_GuiDrawScreenEvent_Pre.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_FixBufferBuilderSortReset.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/compatibility/vanilla/Mixin_FixResourcePackCrash.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/feature/sound/Mixin_ISoundExt_createAccessor.java create mode 100644 versions/1.8.9-forge/src/main/java/gg/essential/mixins/transformers/resources/LegacyAdapterPackMixin_Ext.java create mode 100644 versions/1.8.9-forge/src/main/resources/pack.mcmeta create mode 100644 versions/aliases.txt create mode 100644 versions/api/1.20.2-forge/gradle.properties create mode 100644 versions/api/1.20.4-fabric/gradle.properties create mode 100644 versions/api/1.20.4-forge/gradle.properties create mode 100644 versions/api/mainProject create mode 100644 versions/mainProject diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..299f082 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* eol=lf +*.der binary +*.otf binary +*.ttf binary +*.png binary +*.jar binary \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..b3d734a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,86 @@ +name: Build + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest-16-cores + env: + GRADLE_ARGS: --stacktrace + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: | + 8 + 16 + 17 + 21 + + - name: Build + run: ./gradlew build ${{ env.GRADLE_ARGS }} + + - name: Generate checksums + run: sha256sum versions/*/build/libs/* loader/{container,stage{0,1,2}}/{fabric,launchwrapper,modlauncher{8,9}}/build/libs/* | tee checksums.txt + + - uses: actions/upload-artifact@v3 + with: + name: checksums + path: checksums.txt + + - name: Verify deployed checksums + id: verify + shell: bash + run: | + declare -A platform_checksums + for file in versions/*/build/libs/Essential*; do + platform=$(echo "$file" | cut -d / -f2) + checksum=$(sha256sum < "$file" | awk '{print $1}') + platform_checksums["$platform"]="$checksum" + done + while read -r alias source; do + platform_checksums["$alias"]="${platform_checksums["$source"]}" + done < versions/aliases.txt + + # 1.16.2 are only published as 1.16.5 + unset platform_checksums["1.16.2-fabric"] + unset platform_checksums["1.16.2-forge"] + + version="$(grep "^version=" gradle.properties | cut -d'=' -f2)" + version="${version::-12}" # remove git version suffix + + success=true + for platform in "${!platform_checksums[@]}"; do + expected_checksum="${platform_checksums["$platform"]}" + infra_platform="$(echo "$platform" | sed -r 's/(.+)-(.+)/\2_\1/g' | sed 's/\./-/g' )" + meta_url="https://api.essential.gg/mods/v1/essential:essential/versions/$version/platforms/$infra_platform/download" + echo "Checking $meta_url" + download_url="$(curl -s "$meta_url" | jq -r .url)" + echo " -> $download_url" + actual_checksum="$(curl -s "$download_url" | sha256sum | awk '{print $1}')" + echo " -> $actual_checksum" + if [[ "$expected_checksum" == "$actual_checksum" ]]; then + echo " -> OK" + else + echo " -> MISMATCH, expected $expected_checksum" + success=false + fi + done + + if [[ "$success" != true ]]; then + exit 1 + fi + + # Upload the raw jars to aid in debugging non-determinism issues if we got a checksum mismatch + - uses: actions/upload-artifact@v3 + if: ${{ failure() && steps.verify.conclusion == 'failure' }} + with: + name: mod-jars + path: versions/*/build/libs/Essential* + # These are fairly large, so we do not want to keep them around for particularly long. + # Once fully deployed, one can simply fetch the jar from Essential infrastructure instead. + retention-days: 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eb6416 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# eclipse +eclipse +bin +*.launch +.settings +.metadata +.classpath +.project + +# idea +out +classes +*.ipr +*.iws +*.iml +.idea +.run + +# gradle +build +.gradle + +#Netbeans +.nb-gradle +.nb-gradle-properties + +# other +run +.DS_Store +Thumbs.db +.vscode + +versions/*/tmp.srg +versions/api/*/api + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c314f38 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "loader"] + path = loader + url = ../../EssentialGG/EssentialLoader.git diff --git a/.init b/.init deleted file mode 100644 index e69de29..0000000 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf4f333 --- /dev/null +++ b/LICENSE @@ -0,0 +1,31 @@ +In the interest of transparency, ModCore Inc., doing business as +Essential (“Essential”), is making available the code of its +Essential™ modification to the Minecraft® game (collectively with all +updates thereto and versions thereof, the “Mod”) solely to enable you +to view and audit compiled binaries of the Mod as distributed by +Essential and third parties to verify that these distributed versions +of the Mod are authentic and directly derived from Modcore’s published +source code for the Mod without modification or tampering. This +permission does not constitute any license or transfer of rights in +and to the Mod or its underlying code, and you may not copy, +reproduce, modify, sell, license, distribute, commercialize, or +otherwise exploit, or create derivative works based upon, the Mod or +its underlying code, all of which is reserved by Essential. + +THE MOD IS PROVIDED TO YOU “AS IS” AND WITHOUT ANY WARRANTY OF ANY +KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING, +WITHOUT LIMITATION, ALL WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, OR ARISING OUT OF COURSE +OF DEALING, COURSE OF PERFORMANCE, USAGE, OR TRADE PRACTICE, ALL OF +WHICH ARE HEREBY EXPRESSLY DISCLAIMED TO THE MAXIMUM EXTENT PERMITTED +BY LAW. ESSENTIAL MAKES NO REPRESENTATION OR WARRANTY OF ANY KIND THAT +THE MOD WILL MEET YOUR REQUIREMENTS, BE COMPATIBLE OR WORK WITH ANY +OTHER SOFTWARE, SYSTEMS, OR SERVICES, OPERATE WITHOUT INTERRUPTION, +MEET ANY PERFORMANCE OR RELIABILITY STANDARDS, OR BE ERROR-FREE, OR +THAT ANY ERRORS OR DEFECTS CAN OR WILL BE CORRECTED. + +Please email us at contact@essential.gg if you believe that any +distributed version of Essential™ does not match the official version +of Essential™ as produced by this repository so that we can resolve +any discrepancies and ensure community trust in Essential’s products +and services. diff --git a/README.md b/README.md new file mode 100644 index 0000000..46e0875 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# Essential + +Essential is a quality of life mod that boosts Minecraft Java Edition to the next level! + +The source code of the Essential Mod is accessible to everyone, demonstrating our commitment to transparency with our +users and the broader community. + +For assistance with this repository or the mod, please utilize the support channels available in our [Discord](https://essential.gg/discord). + +Discover more about Essential on our [Website](https://essential.gg) or visit the [Essential Wiki](https://essential.gg/wiki). + +## Building + +Before building Essential, you must have [Java Development Kits (JDKs)](https://adoptium.net/temurin/releases/) +installed for Java versions 21, 17, 16, and 8 (even if you only want to build for a specific Minecraft version). +Java 21 (or newer) must be the default Java version on your system. + +No additional tools are required. Gradle will be automatically be installed by the +[gradle-wrapper](https://docs.gradle.org/current/userguide/gradle_wrapper.html) program included in the repository and +available via the `./gradlew` (Linux/Mac) or `gradlew.bat` (Windows) scripts. +We highly recommend using these instead of a local installation of Gradle to ensure you're using the exact same version +of Gradle as was used for the official builds. We cannot guarantee that older or newer versions will work and produce +bit-for-bit identical output. + +Note that this repository uses [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules). +Be sure to run `git submodule update --init --recursive` after cloning the repository for the first time (or clone with +`--recursive`), and also every time after you pull a new version. + +### Building Essential Mod + +To build all of Essential for all Minecraft versions, run `./gradlew build`. +Depending on your system and internet connection the first build may take anywhere from 10 minutes to an hour. + +To build for a specific Minecraft version, run `./gradlew :-:build`, e.g. for Minecraft 1.12.2 run +`./gradlew :1.12.2-forge:build`. +Note that building any version other than the main version (currently 1.12.2) will require all versions between it and +the main version to be set up regardless, so the time saved over building for all versions may vary wildly. + +Once finished, you should be able to find the Essential jars in `versions//build/libs/`. + +The jar file starting with `pinned_` is the mod file made available via Modrinth/CurseForge. +The other jar file is downloaded by the in-game update functionality, third-party mods which embed the +Essential Loader, the thin container mods available on essential.gg/download, as well as the Essential Installer. + +### Building Essential Loader + +The latest version of Essential Loader is automatically built when building [Essential](#building-essential-mod) because +it is included in the `pinned` jar files. + +The loader is split into three "stages" (for details see `loader/docs/stages.md`) each with one jar per "platform" +(for details see `loader/docs/platforms.md`). +You can find these jar files in `loader///build/libs/`. + +### Building Essential Container + +The Essential Container is a thin mod which does nothing but download Essential on first launch. +The jar files available for download on essential.gg/download and installed by the Essential Installer are such +"Container" files. +Unlike the jars published on Modrinth/CurseForge ("pinned" jars), it does not contain a specific version of Essential, +rather it downloads whatever version is the latest at first launch. + +Given it only contains code to load Essential and no code to directly interact with Minecraft itself, there are only +four different versions: +- `fabric` for everything Fabric +- `launchwrapper` for Forge on Minecraft 1.8.9 and 1.12.2 +- `modlauncher8` for Forge on Minecraft 1.16.5 +- `modlauncher9` for Forge on Minecraft 1.17 and above + +For more technical details about these different platforms, see `loader/docs/platforms.md`. +For more technical details about "container"/"pinned" mods, see `loader/docs/container-mods.md`. + +To build the Essential Container, run `./gradlew :loader:container::build` where `` is one of the +four platforms listed above. +You can find the resulting jar file in `loader/container//build/libs/`. + +## CI + +Every Essential release is built by CI twice, once by our main (internal, self-hosted) CI system and a second time +directly from this repository on a GitHub-provided runner. + +The first internal run is generally much faster and includes a few extra steps such as integration tests, uploading +(but not yet publishing) of the jars to our infrastructure, as well as publishing the source code to this repository. + +The second run, directly from this repository, exists primarily to ensure that the source code we publish actually +matches the jars that were produced and uploaded by the first run. +After building the mod from scratch directly from publicly accessible source code in the GitHub-provided clean +environment, it will download the main jars from our infrastructure and ensure that they are bit-for-bit identical to +the ones it just built. + +It will also log and make available as an artifact via GitHub the checksums of the files it built, such that +third-parties may independently verify the files served by our infrastructure without having to build the entire mod +themselves. +Note that GitHub will however unfortunately delete Actions logs and artifacts after some time. + +It will not verify the checksums of the "pinned" jars (those available via Modrinth/CurseForge) because these are +deterministically derived from the main jars (see `build-logic/src/main/kotlin/essential/pinned-jar.gradle.kts`), so +verifying the main jars is sufficient. +Our internal CI does not even upload these pinned jars, they are re-generated on demand when publishing to +Modrinth/CurseForge. +Their checksums are printed to the publicly visible log during the second run though, so third-parties may at any time +compare them to the files served by Modrinth/CurseForge. + +## Verifying checksums + +To verify checksums of any Essential-related files from your `.minecraft` folder, first either +[build](#building-essential-mod) the respective Essential version, or find the corresponding GitHub Actions run and +download its `checksums` text file / look at the `Generate checksums` log section of its `build` job. + +Then use the below sub-sections to identify which kind of file you are looking at, as well as what path to find the +respective jar file at in this repository. + +If you have built Essential yourself, you may then compare the file at the given path to the one in your `.minecraft` +folder. +If you are looking at the GitHub Actions run, you are looking at a list of files with their corresponding +[SHA-256 checksum](https://en.wikipedia.org/wiki/SHA-2). Use a program (e.g. `sha256sum` on Linux) to generate the +checksum of the file in your `.minecraft` folder and compare it to the checksum of the file in the list. + +Note that some Essential versions are compatible with multiple Minecraft versions, see the `versions/aliases.txt` for +an exact mapping, or simply compare with the next available Minecraft versions above and below your version. + +Note that not all files in your `.minecraft` folder are updated at the same time, so some of them may be from older +Essential versions. + +Note that if your installation of Essential is older, there may still be mod and loader files in there from before +source code has been made publicly accessible and even from before builds were deterministic. +If you still have such files and are concerned about them, please get in contact with us and we will try to verify its +authenticity. + +### Files in the .minecraft/mods folder + +If your jar file is smaller than one megabyte (typically includes a Minecraft version but never an Essential version in +its name), +it should be an [Essential Container](#building-essential-container) file. +Please refer to the linked section for which "platform" corresponds to your Minecraft + Mod Loader and where the built +jar file can be found. + +If your jar file is much larger (typically includes both the Minecraft version and the Essential version in its name), +it should be the `pinned_` file found in `versions//build/libs/`. + +### Files in the .minecraft/essential folder + +If your file is named `Essential (_).jar`, +it should be the main `Essential ` (not the `pinned_`) file found in `versions//build/libs/`. + +If your file is named `Essential (_).processed.jar`, +it is a temporary file derived from the above file with the same name without the `processed` suffix. +If you delete it, it will be re-generated from that file on next launch. + +### Files in the .minecraft/essential/libraries folder + +These are extracted from the main Essential jar [in the .minecraft/essentials](#files-in-the-minecraftessential-folder) +(from its `META-INF/jars/` folder, as well as recursively for those jars). +If you delete them, those that are still used by your current Essential version will be re-extracted on next launch. + +### Files in the .minecraft/essential/loader folder + +If your file is named `stage1.jar`, it is extracted from +[the mods in your .minecraft/mods](#files-in-the-minecraftmods-folder) folder and from the main Essential jar +[in the .minecraft/essentials](#files-in-the-minecraftessential-folder) folder (whichever has the most recent version). +In either case, unless you are on an ancient Essential version, it should match the jar file found in +`loader/stage1//build/libs/` where `` is one of the four listed in +[this section of the README](#building-essential-container). + +If your file is named `stage2._.jar`, it should match the jar file found in +`loader/stage2//build/libs/` where `` is one of the four listed in +[this section of the README](#building-essential-container). +Note that this file in particular is not at all updated in lockstep with Essential, so its version may very well be +older or even newer than the one which this repo references for your Essential version. +It should usually be accompanied by a `.meta` file though which should contain its version (not the Essential version), +you may then be able to find this version in the `loader` repository and build it specifically. +Note that even though you found this file is in the `stage1` folder, it is the `stage2` of the loader (the reason it is +in the `stage1` folder is because `stage1` loads it). + +## License + +Below, you'll find an outline of what is permitted and what is restricted under the source-available license of the +Essential Mod's source code. + +**What you CAN do** + +- Audit the source code +- Compile the source code to confirm the authenticity of the official releases + +**What you CANNOT do** + +- Utilize any code or assets, including for personal use +- Incorporate the source code in any other projects or use our code as a reference in new projects +- Modify or alter the source code provided here +- Distributing compiled versions of the source code or modified source code + +This summary is not an exhaustive interpretation of the license; for a comprehensive understanding, please refer to [the +full license file](https://github.com/EssentialGG/Essential/blob/main/LICENSE). diff --git a/api/api/api.api b/api/api/api.api new file mode 100644 index 0000000..21526e3 --- /dev/null +++ b/api/api/api.api @@ -0,0 +1,729 @@ +public abstract class gg/essential/api/DI : org/kodein/di/DIAware { + public fun ()V + public abstract fun addModule (Lorg/kodein/di/DI$Module;)V + public fun getDiContext ()Lorg/kodein/di/DIContext; + public fun getDiTrigger ()Lorg/kodein/di/DITrigger; + protected final fun init ()V +} + +public abstract interface class gg/essential/api/EssentialAPI { + public static final field Companion Lgg/essential/api/EssentialAPI$Companion; + public abstract fun commandRegistry ()Lgg/essential/api/commands/CommandRegistry; + public abstract fun componentFactory ()Lgg/essential/api/gui/EssentialComponentFactory; + public abstract fun config ()Lgg/essential/api/config/EssentialConfig; + public abstract fun di ()Lgg/essential/api/DI; + public static fun getCommandRegistry ()Lgg/essential/api/commands/CommandRegistry; + public static fun getConfig ()Lgg/essential/api/config/EssentialConfig; + public static fun getDI ()Lgg/essential/api/DI; + public static fun getEssentialComponentFactory ()Lgg/essential/api/gui/EssentialComponentFactory; + public static fun getGuiUtil ()Lgg/essential/api/utils/GuiUtil; + public static fun getImageCache ()Lgg/essential/elementa/components/image/ImageCache; + public static fun getInstance ()Lgg/essential/api/EssentialAPI; + public static fun getMinecraftUtil ()Lgg/essential/api/utils/MinecraftUtils; + public static fun getMojangAPI ()Lgg/essential/api/utils/mojang/MojangAPI; + public static fun getNotifications ()Lgg/essential/api/gui/Notifications; + public static fun getOnboardingData ()Lgg/essential/api/data/OnboardingData; + public static fun getShutdownHookUtil ()Lgg/essential/api/utils/ShutdownHookUtil; + public static fun getTrustedHostsUtil ()Lgg/essential/api/utils/TrustedHostsUtil; + public abstract fun guiUtil ()Lgg/essential/api/utils/GuiUtil; + public abstract fun imageCache ()Lgg/essential/elementa/components/image/ImageCache; + public abstract fun minecraftUtil ()Lgg/essential/api/utils/MinecraftUtils; + public abstract fun mojangAPI ()Lgg/essential/api/utils/mojang/MojangAPI; + public abstract fun notifications ()Lgg/essential/api/gui/Notifications; + public abstract fun onboardingData ()Lgg/essential/api/data/OnboardingData; + public abstract fun shutdownHookUtil ()Lgg/essential/api/utils/ShutdownHookUtil; + public abstract fun trustedHostsUtil ()Lgg/essential/api/utils/TrustedHostsUtil; +} + +public final class gg/essential/api/EssentialAPI$Companion { + public final fun getCommandRegistry ()Lgg/essential/api/commands/CommandRegistry; + public final fun getConfig ()Lgg/essential/api/config/EssentialConfig; + public final fun getDI ()Lgg/essential/api/DI; + public final fun getEssentialComponentFactory ()Lgg/essential/api/gui/EssentialComponentFactory; + public final fun getGuiUtil ()Lgg/essential/api/utils/GuiUtil; + public final fun getImageCache ()Lgg/essential/elementa/components/image/ImageCache; + public final fun getInstance ()Lgg/essential/api/EssentialAPI; + public final fun getMinecraftUtil ()Lgg/essential/api/utils/MinecraftUtils; + public final fun getMojangAPI ()Lgg/essential/api/utils/mojang/MojangAPI; + public final fun getNotifications ()Lgg/essential/api/gui/Notifications; + public final fun getOnboardingData ()Lgg/essential/api/data/OnboardingData; + public final fun getShutdownHookUtil ()Lgg/essential/api/utils/ShutdownHookUtil; + public final fun getTrustedHostsUtil ()Lgg/essential/api/utils/TrustedHostsUtil; +} + +public abstract interface class gg/essential/api/commands/ArgumentParser { + public fun complete (Lgg/essential/api/commands/ArgumentQueue;Ljava/lang/reflect/Parameter;)Ljava/util/List; + public abstract fun parse (Lgg/essential/api/commands/ArgumentQueue;Ljava/lang/reflect/Parameter;)Ljava/lang/Object; +} + +@1.12.2-forge,1.8.9-forge +public final class gg/essential/api/commands/ArgumentParser$DefaultImpls { + public static fun complete (Lgg/essential/api/commands/ArgumentParser;Lgg/essential/api/commands/ArgumentQueue;Ljava/lang/reflect/Parameter;)Ljava/util/List; +} + +public abstract interface class gg/essential/api/commands/ArgumentQueue { + public abstract fun isEmpty ()Z + public abstract fun peek ()Ljava/lang/String; + public abstract fun poll ()Ljava/lang/String; +} + +public abstract class gg/essential/api/commands/Command { + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Z)V + public fun (Ljava/lang/String;ZZ)V + public synthetic fun (Ljava/lang/String;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAutoHelpSubcommand ()Z + public fun getCommandAliases ()Ljava/util/Set; + public final fun getDefaultHandler ()Lgg/essential/api/commands/Command$Handler; + public final fun getHideFromAutocomplete ()Z + public final fun getName ()Ljava/lang/String; + public final fun getSubCommands ()Ljava/util/Map; + public final fun getUniqueSubCommands ()Ljava/util/List; + public final fun register ()V +} + +public final class gg/essential/api/commands/Command$Alias { + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Z + public final fun copy (Ljava/lang/String;Z)Lgg/essential/api/commands/Command$Alias; + public static synthetic fun copy$default (Lgg/essential/api/commands/Command$Alias;Ljava/lang/String;ZILjava/lang/Object;)Lgg/essential/api/commands/Command$Alias; + public fun equals (Ljava/lang/Object;)Z + public final fun getAlias ()Ljava/lang/String; + public final fun getHideFromAutocomplete ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/commands/Command$Handler { + public fun (Ljava/lang/reflect/Method;)V + public final fun getKParams ()Ljava/util/List; + public final fun getMethod ()Ljava/lang/reflect/Method; + public final fun getParams ()[Ljava/lang/reflect/Parameter; +} + +public abstract interface class gg/essential/api/commands/CommandRegistry { + public abstract fun registerCommand (Lgg/essential/api/commands/Command;)V + public abstract fun registerParser (Ljava/lang/Class;Lgg/essential/api/commands/ArgumentParser;)V + public abstract fun unregisterCommand (Lgg/essential/api/commands/Command;)V +} + +public abstract interface annotation class gg/essential/api/commands/DefaultHandler : java/lang/annotation/Annotation { +} + +public abstract interface annotation class gg/essential/api/commands/DisplayName : java/lang/annotation/Annotation { + public abstract fun value ()Ljava/lang/String; +} + +public abstract interface annotation class gg/essential/api/commands/Greedy : java/lang/annotation/Annotation { +} + +public abstract interface annotation class gg/essential/api/commands/Options : java/lang/annotation/Annotation { + public abstract fun value ()[Ljava/lang/String; +} + +public abstract interface annotation class gg/essential/api/commands/Quotable : java/lang/annotation/Annotation { +} + +public abstract interface annotation class gg/essential/api/commands/SubCommand : java/lang/annotation/Annotation { + public abstract fun aliases ()[Ljava/lang/String; + public abstract fun description ()Ljava/lang/String; + public abstract fun value ()Ljava/lang/String; +} + +public abstract interface annotation class gg/essential/api/commands/Take : java/lang/annotation/Annotation { + public abstract fun allowLess ()Z + public abstract fun value ()I +} + +public abstract interface class gg/essential/api/config/EssentialConfig { + public abstract fun getAutoRefreshSession ()Z + public abstract fun getCosmeticArmorSetting ()I + public abstract fun getCurrentMultiplayerTab ()I + public abstract fun getDisableAllNotifications ()Z + public abstract fun getDiscordAllowAskToJoin ()Z + public abstract fun getDiscordRichPresence ()Z + public abstract fun getDiscordSdk ()Z + public abstract fun getDiscordShowCurrentServer ()Z + public abstract fun getDiscordShowUsernameAndAvatar ()Z + public abstract fun getEnableVanillaScreenshotMessage ()Z + public abstract fun getEssentialFull ()Z + public abstract fun getEssentialGuiScale ()I + public abstract fun getEssentialScreenshots ()Z + public abstract fun getFriendConnectionStatus ()Z + public abstract fun getFriendRequestPrivacy ()I + public abstract fun getGroupMessageReceivedNotifications ()Z + public abstract fun getHideCosmeticsWhenServerOverridesSkin ()Z + public abstract fun getLinkWarning ()Z + public abstract fun getMessageReceivedNotifications ()Z + public abstract fun getMessageSound ()Z + public abstract fun getModCoreWarning ()Z + public abstract fun getOpenToFriends ()Z + public abstract fun getOverrideGuiScale ()Z + public abstract fun getScreenshotSounds ()Z + public abstract fun getSendServerUpdates ()Z + public abstract fun getShowEssentialIndicatorOnNametag ()Z + public abstract fun getShowEssentialIndicatorOnTab ()Z + public abstract fun getSmoothZoomAlgorithm ()I + public abstract fun getSmoothZoomAnimation ()Z + public abstract fun getStreamerMode ()Z + public abstract fun getTimeFormat ()I + public abstract fun getToggleToZoom ()Z + public abstract fun getUpdateModal ()Z + @1.12.2-forge,1.8.9-forge + public abstract fun getWindowedFullscreen ()Z + public abstract fun getZoomSmoothCamera ()Z + public abstract fun setAutoRefreshSession (Z)V + public abstract fun setCosmeticArmorSetting (I)V + public abstract fun setCurrentMultiplayerTab (I)V + public abstract fun setDisableAllNotifications (Z)V + public abstract fun setDiscordAllowAskToJoin (Z)V + public abstract fun setDiscordRichPresence (Z)V + public abstract fun setDiscordSdk (Z)V + public abstract fun setDiscordShowCurrentServer (Z)V + public abstract fun setDiscordShowUsernameAndAvatar (Z)V + public abstract fun setEnableVanillaScreenshotMessage (Z)V + public abstract fun setEssentialFull (Z)V + public abstract fun setEssentialGuiScale (I)V + public abstract fun setEssentialScreenshots (Z)V + public abstract fun setFriendConnectionStatus (Z)V + public abstract fun setFriendRequestPrivacy (I)V + public abstract fun setGroupMessageReceivedNotifications (Z)V + public abstract fun setHideCosmeticsWhenServerOverridesSkin (Z)V + public abstract fun setLinkWarning (Z)V + public abstract fun setMessageReceivedNotifications (Z)V + public abstract fun setMessageSound (Z)V + public abstract fun setModCoreWarning (Z)V + public abstract fun setOpenToFriends (Z)V + public abstract fun setOverrideGuiScale (Z)V + public abstract fun setScreenshotSounds (Z)V + public abstract fun setSendServerUpdates (Z)V + public abstract fun setShowEssentialIndicatorOnNametag (Z)V + public abstract fun setShowEssentialIndicatorOnTab (Z)V + public abstract fun setSmoothZoomAlgorithm (I)V + public abstract fun setSmoothZoomAnimation (Z)V + public abstract fun setStreamerMode (Z)V + public abstract fun setTimeFormat (I)V + public abstract fun setToggleToZoom (Z)V + public abstract fun setUpdateModal (Z)V + @1.12.2-forge,1.8.9-forge + public abstract fun setWindowedFullscreen (Z)V + public abstract fun setZoomSmoothCamera (Z)V +} + +public abstract interface class gg/essential/api/cosmetics/RenderCosmetic { +} + +public abstract interface class gg/essential/api/data/OnboardingData { + public abstract fun hasAcceptedEssentialTOS ()Z + public abstract fun hasDeniedEssentialTOS ()Z +} + +public final class gg/essential/api/gui/ConfirmationModalBuilder { + public fun ()V + public final fun build ()Lgg/essential/elementa/UIComponent; + public final fun build (Lgg/essential/api/gui/EssentialComponentFactory;)Lgg/essential/elementa/UIComponent; + public static synthetic fun build$default (Lgg/essential/api/gui/ConfirmationModalBuilder;Lgg/essential/api/gui/EssentialComponentFactory;ILjava/lang/Object;)Lgg/essential/elementa/UIComponent; + public final fun getConfirmButtonText ()Ljava/lang/String; + public final fun getDenyButtonText ()Ljava/lang/String; + public final fun getInputPlaceholder ()Ljava/lang/String; + public final fun getOnConfirm ()Lkotlin/jvm/functions/Function1; + public final fun getOnDeny ()Lkotlin/jvm/functions/Function0; + public final fun getSecondaryText ()Ljava/lang/String; + public final fun getText ()Ljava/lang/String; + public final fun setConfirmButtonText (Ljava/lang/String;)V + public final fun setDenyButtonText (Ljava/lang/String;)V + public final fun setInputPlaceholder (Ljava/lang/String;)V + public final fun setOnConfirm (Lkotlin/jvm/functions/Function1;)V + public final fun setOnDeny (Lkotlin/jvm/functions/Function0;)V + public final fun setSecondaryText (Ljava/lang/String;)V + public final fun setText (Ljava/lang/String;)V +} + +public final class gg/essential/api/gui/EmulatedPlayerBuilder { + public fun ()V + public final fun build ()Lgg/essential/elementa/UIComponent; + public final fun build (Lgg/essential/api/gui/EssentialComponentFactory;)Lgg/essential/elementa/UIComponent; + public static synthetic fun build$default (Lgg/essential/api/gui/EmulatedPlayerBuilder;Lgg/essential/api/gui/EssentialComponentFactory;ILjava/lang/Object;)Lgg/essential/elementa/UIComponent; + public final fun getDraggable ()Z + public final fun getDraggableState ()Lgg/essential/elementa/state/State; + public final fun getProfile ()Lcom/mojang/authlib/GameProfile; + public final fun getProfileState ()Lgg/essential/elementa/state/State; + public final fun getRenderNameTag ()Z + public final fun getRenderNameTagState ()Lgg/essential/elementa/state/State; + public final fun getShowCape ()Z + public final fun getShowCapeState ()Lgg/essential/elementa/state/State; + public final fun getWrappedProfileState ()Lgg/essential/elementa/state/State; + public final fun setDraggable (Z)V + public final fun setDraggableState (Lgg/essential/elementa/state/State;)V + public final fun setProfile (Lcom/mojang/authlib/GameProfile;)V + public final fun setProfileState (Lgg/essential/elementa/state/State;)V + public final fun setRenderNameTag (Z)V + public final fun setRenderNameTagState (Lgg/essential/elementa/state/State;)V + public final fun setShowCape (Z)V + public final fun setShowCapeState (Lgg/essential/elementa/state/State;)V + public final fun setWrappedProfileState (Lgg/essential/elementa/state/State;)V +} + +public abstract interface class gg/essential/api/gui/EssentialComponentFactory { + public abstract fun build (Lgg/essential/api/gui/ConfirmationModalBuilder;)Lgg/essential/elementa/UIComponent; + public abstract fun build (Lgg/essential/api/gui/EmulatedPlayerBuilder;)Lgg/essential/elementa/UIComponent; + public fun buildEmulatedPlayer (ZZ)Lgg/essential/elementa/UIComponent; + public static synthetic fun buildEmulatedPlayer$default (Lgg/essential/api/gui/EssentialComponentFactory;ZZILjava/lang/Object;)Lgg/essential/elementa/UIComponent; +} + +@1.12.2-forge,1.8.9-forge +public final class gg/essential/api/gui/EssentialComponentFactory$DefaultImpls { + public static fun buildEmulatedPlayer (Lgg/essential/api/gui/EssentialComponentFactory;ZZ)Lgg/essential/elementa/UIComponent; + public static synthetic fun buildEmulatedPlayer$default (Lgg/essential/api/gui/EssentialComponentFactory;ZZILjava/lang/Object;)Lgg/essential/elementa/UIComponent; +} + +public final class gg/essential/api/gui/EssentialComponentFactoryKt { + public static final fun buildConfirmationModal (Lgg/essential/api/gui/EssentialComponentFactory;Lkotlin/jvm/functions/Function1;)Lgg/essential/elementa/UIComponent; + public static final fun buildEmulatedPlayer (Lgg/essential/api/gui/EssentialComponentFactory;Lkotlin/jvm/functions/Function1;)Lgg/essential/elementa/UIComponent; +} + +public class gg/essential/api/gui/EssentialGUI : gg/essential/elementa/WindowScreen { + public fun ()V + public fun (Lgg/essential/elementa/ElementaVersion;)V + public fun (Lgg/essential/elementa/ElementaVersion;Ljava/lang/String;)V + public fun (Lgg/essential/elementa/ElementaVersion;Ljava/lang/String;I)V + public fun (Lgg/essential/elementa/ElementaVersion;Ljava/lang/String;IZ)V + public synthetic fun (Lgg/essential/elementa/ElementaVersion;Ljava/lang/String;IZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lgg/essential/elementa/ElementaVersion;Ljava/lang/String;IZLjava/lang/String;)V + public synthetic fun (Lgg/essential/elementa/ElementaVersion;Ljava/lang/String;IZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;I)V + public fun (Ljava/lang/String;IZ)V + public synthetic fun (Ljava/lang/String;IZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBackButtonVisible ()Z + public final fun getContent ()Lgg/essential/elementa/components/UIContainer; + public final fun getDiscordActivityDescription ()Ljava/lang/String; + public final fun getRightDivider ()Lgg/essential/elementa/components/UIBlock; + public final fun getScissorBox ()Lgg/essential/elementa/components/UIContainer; + public final fun getTitleBar ()Lgg/essential/elementa/components/UIBlock; + public final fun getTitleText ()Lgg/essential/elementa/components/UIWrappedText; + public final fun setBackButtonVisible (Z)V + public final fun setTitle (Ljava/lang/String;)V +} + +public abstract interface class gg/essential/api/gui/GuiRequiresTOS { +} + +public abstract interface class gg/essential/api/gui/NotificationBuilder { + public abstract fun getDismissNotification ()Lkotlin/jvm/functions/Function0; + public abstract fun getDismissNotificationInstantly ()Lkotlin/jvm/functions/Function0; + public abstract fun getDuration ()F + public abstract fun getElementaVersion ()Lgg/essential/elementa/ElementaVersion; + public abstract fun getOnAction ()Lkotlin/jvm/functions/Function0; + public abstract fun getOnClose ()Lkotlin/jvm/functions/Function0; + public abstract fun getTimerEnabled ()Lgg/essential/elementa/state/State; + public abstract fun getTrimMessage ()Z + public abstract fun getTrimTitle ()Z + public abstract fun getType ()Lgg/essential/api/gui/NotificationType; + public abstract fun getUniqueId ()Ljava/lang/Object; + public abstract fun setDuration (F)V + public abstract fun setElementaVersion (Lgg/essential/elementa/ElementaVersion;)V + public abstract fun setOnAction (Lkotlin/jvm/functions/Function0;)V + public abstract fun setOnClose (Lkotlin/jvm/functions/Function0;)V + public abstract fun setTimerEnabled (Lgg/essential/elementa/state/State;)V + public abstract fun setTrimMessage (Z)V + public abstract fun setTrimTitle (Z)V + public abstract fun setType (Lgg/essential/api/gui/NotificationType;)V + public abstract fun setUniqueId (Ljava/lang/Object;)V + public abstract fun withCustomComponent (Lgg/essential/api/gui/Slot;Lgg/essential/elementa/UIComponent;)Lgg/essential/api/gui/NotificationBuilder; + public fun withElementaVersion (Lgg/essential/elementa/ElementaVersion;)Lgg/essential/api/gui/NotificationBuilder; +} + +@1.12.2-forge,1.8.9-forge +public final class gg/essential/api/gui/NotificationBuilder$DefaultImpls { + public static fun withElementaVersion (Lgg/essential/api/gui/NotificationBuilder;Lgg/essential/elementa/ElementaVersion;)Lgg/essential/api/gui/NotificationBuilder; +} + +public final class gg/essential/api/gui/NotificationType : java/lang/Enum { + public static final field DISCORD Lgg/essential/api/gui/NotificationType; + public static final field ERROR Lgg/essential/api/gui/NotificationType; + public static final field GENERAL Lgg/essential/api/gui/NotificationType; + public static final field INFO Lgg/essential/api/gui/NotificationType; + public static final field WARNING Lgg/essential/api/gui/NotificationType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lgg/essential/api/gui/NotificationType; + public static fun values ()[Lgg/essential/api/gui/NotificationType; +} + +public abstract interface class gg/essential/api/gui/Notifications { + public abstract fun push (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun push (Ljava/lang/String;Ljava/lang/String;F)V + public abstract fun push (Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;)V + public abstract fun push (Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public abstract fun push (Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V + public abstract fun push (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public abstract fun push (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V +} + +@1.12.2-forge,1.8.9-forge +public final class gg/essential/api/gui/Notifications$DefaultImpls { + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;FLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun push$default (Lgg/essential/api/gui/Notifications;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V +} + +public final class gg/essential/api/gui/Slot : java/lang/Enum { + public static final field ACTION Lgg/essential/api/gui/Slot; + public static final field ICON Lgg/essential/api/gui/Slot; + public static final field LARGE_PREVIEW Lgg/essential/api/gui/Slot; + public static final field PREVIEW Lgg/essential/api/gui/Slot; + public static final field SMALL_PREVIEW Lgg/essential/api/gui/Slot; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lgg/essential/api/gui/Slot; + public static fun values ()[Lgg/essential/api/gui/Slot; +} + +public final class gg/essential/api/profile/WrappedGameProfile { + public fun (Lcom/mojang/authlib/GameProfile;)V + public fun (Ljava/util/UUID;Ljava/lang/String;)V + public final fun copy ()Lgg/essential/api/profile/WrappedGameProfile; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/util/UUID; + public final fun getName ()Ljava/lang/String; + public final fun getProfile ()Lcom/mojang/authlib/GameProfile; + public final fun getProperties ()Lcom/mojang/authlib/properties/PropertyMap; + public fun hashCode ()I +} + +public final class gg/essential/api/profile/WrappedGameProfileKt { + public static final fun wrapped (Lcom/mojang/authlib/GameProfile;)Lgg/essential/api/profile/WrappedGameProfile; +} + +public final class gg/essential/api/utils/DiKt { + public static final fun getEssentialDI ()Lgg/essential/api/DI; + public static final fun getInitialised ()Z +} + +public abstract interface class gg/essential/api/utils/GuiUtil { + public static final field Companion Lgg/essential/api/utils/GuiUtil$Companion; + public abstract fun getGuiScale ()I + public abstract fun getGuiScale (I)I + @1.17.1-forge,1.18.2-forge,1.19.2-forge,1.19.3-forge,1.19.4-forge,1.20.1-forge,1.20.2-forge,1.20.4-forge + public static fun getOpenedScreen ()Lnet/minecraft/client/gui/screens/Screen; + @1.17.1-forge,1.18.2-forge,1.19.2-forge,1.19.3-forge,1.19.4-forge,1.20.1-forge,1.20.2-forge,1.20.4-forge + public static fun open (Lnet/minecraft/client/gui/screens/Screen;)V + @1.17.1-forge,1.18.2-forge,1.19.2-forge,1.19.3-forge,1.19.4-forge,1.20.1-forge,1.20.2-forge,1.20.4-forge + public abstract fun openScreen (Lnet/minecraft/client/gui/screens/Screen;)V + @1.17.1-forge,1.18.2-forge,1.19.2-forge,1.19.3-forge,1.19.4-forge,1.20.1-forge,1.20.2-forge,1.20.4-forge + public abstract fun openedScreen ()Lnet/minecraft/client/gui/screens/Screen; + @1.16.2-fabric,1.16.2-forge,1.17.1-fabric,1.18.1-fabric,1.18.2-fabric,1.19-fabric,1.19.2-fabric,1.19.3-fabric,1.19.4-fabric,1.20-fabric,1.20.1-fabric,1.20.2-fabric,1.20.4-fabric,1.20.6-fabric + public static fun getOpenedScreen ()Lnet/minecraft/client/gui/screen/Screen; + @1.16.2-fabric,1.16.2-forge,1.17.1-fabric,1.18.1-fabric,1.18.2-fabric,1.19-fabric,1.19.2-fabric,1.19.3-fabric,1.19.4-fabric,1.20-fabric,1.20.1-fabric,1.20.2-fabric,1.20.4-fabric,1.20.6-fabric + public static fun open (Lnet/minecraft/client/gui/screen/Screen;)V + @1.16.2-fabric,1.16.2-forge,1.17.1-fabric,1.18.1-fabric,1.18.2-fabric,1.19-fabric,1.19.2-fabric,1.19.3-fabric,1.19.4-fabric,1.20-fabric,1.20.1-fabric,1.20.2-fabric,1.20.4-fabric,1.20.6-fabric + public abstract fun openScreen (Lnet/minecraft/client/gui/screen/Screen;)V + @1.16.2-fabric,1.16.2-forge,1.17.1-fabric,1.18.1-fabric,1.18.2-fabric,1.19-fabric,1.19.2-fabric,1.19.3-fabric,1.19.4-fabric,1.20-fabric,1.20.1-fabric,1.20.2-fabric,1.20.4-fabric,1.20.6-fabric + public abstract fun openedScreen ()Lnet/minecraft/client/gui/screen/Screen; + @1.12.2-forge,1.8.9-forge + public static fun getOpenedScreen ()Lnet/minecraft/client/gui/GuiScreen; + @1.12.2-forge,1.8.9-forge + public static fun open (Lnet/minecraft/client/gui/GuiScreen;)V + @1.12.2-forge,1.8.9-forge + public abstract fun openScreen (Lnet/minecraft/client/gui/GuiScreen;)V + @1.12.2-forge,1.8.9-forge + public abstract fun openedScreen ()Lnet/minecraft/client/gui/GuiScreen; +} + +public final class gg/essential/api/utils/GuiUtil$Companion { + @1.17.1-forge,1.18.2-forge,1.19.2-forge,1.19.3-forge,1.19.4-forge,1.20.1-forge,1.20.2-forge,1.20.4-forge + public final fun getOpenedScreen ()Lnet/minecraft/client/gui/screens/Screen; + @1.17.1-forge,1.18.2-forge,1.19.2-forge,1.19.3-forge,1.19.4-forge,1.20.1-forge,1.20.2-forge,1.20.4-forge + public final fun open (Lnet/minecraft/client/gui/screens/Screen;)V + @1.16.2-fabric,1.16.2-forge,1.17.1-fabric,1.18.1-fabric,1.18.2-fabric,1.19-fabric,1.19.2-fabric,1.19.3-fabric,1.19.4-fabric,1.20-fabric,1.20.1-fabric,1.20.2-fabric,1.20.4-fabric,1.20.6-fabric + public final fun getOpenedScreen ()Lnet/minecraft/client/gui/screen/Screen; + @1.16.2-fabric,1.16.2-forge,1.17.1-fabric,1.18.1-fabric,1.18.2-fabric,1.19-fabric,1.19.2-fabric,1.19.3-fabric,1.19.4-fabric,1.20-fabric,1.20.1-fabric,1.20.2-fabric,1.20.4-fabric,1.20.6-fabric + public final fun open (Lnet/minecraft/client/gui/screen/Screen;)V + @1.12.2-forge,1.8.9-forge + public final fun getOpenedScreen ()Lnet/minecraft/client/gui/GuiScreen; + @1.12.2-forge,1.8.9-forge + public final fun open (Lnet/minecraft/client/gui/GuiScreen;)V +} + +public class gg/essential/api/utils/JsonHolder { + public static field printFormattingException Ljava/lang/ThreadLocal; + public fun ()V + public fun (Lcom/google/gson/JsonObject;)V + public fun (Ljava/lang/String;)V + public fun defaultOptJSONArray (Ljava/lang/String;Lcom/google/gson/JsonArray;)Lcom/google/gson/JsonArray; + public fun defaultOptString (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public fun ensureJsonArray (Ljava/lang/String;)V + public fun ensureJsonHolder (Ljava/lang/String;)V + public fun getKeys ()Ljava/util/List; + public fun getObject ()Lcom/google/gson/JsonObject; + public fun getSize ()I + public fun has (Ljava/lang/String;)Z + public fun isNull (Ljava/lang/String;)Z + public fun merge (Lgg/essential/api/utils/JsonHolder;Z)V + public fun mergeNotOverride (Lgg/essential/api/utils/JsonHolder;)V + public fun mergeOverride (Lgg/essential/api/utils/JsonHolder;)V + public fun optActualJSONObject (Ljava/lang/String;)Lcom/google/gson/JsonObject; + public fun optBoolean (Ljava/lang/String;)Z + public fun optBoolean (Ljava/lang/String;Z)Z + public fun optDouble (Ljava/lang/String;)D + public fun optDouble (Ljava/lang/String;D)D + public fun optInt (Ljava/lang/String;)I + public fun optInt (Ljava/lang/String;I)I + public fun optJSONArray (Ljava/lang/String;)Lcom/google/gson/JsonArray; + public fun optJSONObject (Ljava/lang/String;)Lgg/essential/api/utils/JsonHolder; + public fun optLong (Ljava/lang/String;)J + public fun optLong (Ljava/lang/String;J)J + public fun optOrCreateJsonArray (Ljava/lang/String;)Lcom/google/gson/JsonArray; + public fun optOrCreateJsonHolder (Ljava/lang/String;)Lgg/essential/api/utils/JsonHolder; + public fun optString (Ljava/lang/String;)Ljava/lang/String; + public fun put (Ljava/lang/String;D)Lgg/essential/api/utils/JsonHolder; + public fun put (Ljava/lang/String;I)Lgg/essential/api/utils/JsonHolder; + public fun put (Ljava/lang/String;J)Lgg/essential/api/utils/JsonHolder; + public fun put (Ljava/lang/String;Lcom/google/gson/JsonArray;)Lgg/essential/api/utils/JsonHolder; + public fun put (Ljava/lang/String;Lcom/google/gson/JsonObject;)Lgg/essential/api/utils/JsonHolder; + public fun put (Ljava/lang/String;Lgg/essential/api/utils/JsonHolder;)Lgg/essential/api/utils/JsonHolder; + public fun put (Ljava/lang/String;Ljava/lang/String;)Lgg/essential/api/utils/JsonHolder; + public fun put (Ljava/lang/String;Z)Lgg/essential/api/utils/JsonHolder; + public fun remove (Ljava/lang/String;)V + public fun removeAndGet (Ljava/lang/String;)Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +@1.12.2-forge,1.8.9-forge +public final class gg/essential/api/utils/KotlinAdapter : net/minecraftforge/fml/common/ILanguageAdapter { + public fun ()V + public fun getNewInstance (Lnet/minecraftforge/fml/common/FMLModContainer;Ljava/lang/Class;Ljava/lang/ClassLoader;Ljava/lang/reflect/Method;)Ljava/lang/Object; + public fun setInternalProxies (Lnet/minecraftforge/fml/common/ModContainer;Lnet/minecraftforge/fml/relauncher/Side;Ljava/lang/ClassLoader;)V + public fun setProxy (Ljava/lang/reflect/Field;Ljava/lang/Class;Ljava/lang/Object;)V + public fun supportsStatics ()Z +} + +public abstract interface class gg/essential/api/utils/MinecraftUtils { + @1.17.1-forge,1.18.2-forge,1.19.2-forge,1.19.3-forge,1.19.4-forge,1.20.1-forge,1.20.2-forge,1.20.4-forge + public abstract fun getResourceImage (Lnet/minecraft/resources/ResourceLocation;)Ljava/awt/image/BufferedImage; + @1.16.2-fabric,1.17.1-fabric,1.18.1-fabric,1.18.2-fabric,1.19-fabric,1.19.2-fabric,1.19.3-fabric,1.19.4-fabric,1.20-fabric,1.20.1-fabric,1.20.2-fabric,1.20.4-fabric,1.20.6-fabric + public abstract fun getResourceImage (Lnet/minecraft/util/Identifier;)Ljava/awt/image/BufferedImage; + @1.12.2-forge,1.16.2-forge,1.8.9-forge + public abstract fun getResourceImage (Lnet/minecraft/util/ResourceLocation;)Ljava/awt/image/BufferedImage; + public abstract fun isDevelopment ()Z + public abstract fun isHypixel ()Z + public abstract fun sendChatMessageAndFormat (Ljava/lang/String;)V + public abstract fun sendChatMessageAndFormat (Ljava/lang/String;[Ljava/lang/Object;)V + public abstract fun sendMessage (Lgg/essential/universal/wrappers/message/UTextComponent;)V + public abstract fun sendMessage (Ljava/lang/String;)V + public abstract fun sendMessage (Ljava/lang/String;Ljava/lang/String;)V +} + +public final class gg/essential/api/utils/Multithreading { + public static final field INSTANCE Lgg/essential/api/utils/Multithreading; + public final fun getPOOL ()Ljava/util/concurrent/ThreadPoolExecutor; + public static final fun getPool ()Ljava/util/concurrent/ThreadPoolExecutor; + public static final fun getScheduledPool ()Ljava/util/concurrent/ScheduledExecutorService; + public static final fun runAsync (Ljava/lang/Runnable;)V + public final fun schedule (Ljava/lang/Runnable;JJLjava/util/concurrent/TimeUnit;)Ljava/util/concurrent/ScheduledFuture; + public static final fun schedule (Ljava/lang/Runnable;JLjava/util/concurrent/TimeUnit;)Ljava/util/concurrent/ScheduledFuture; + public final fun setPOOL (Ljava/util/concurrent/ThreadPoolExecutor;)V + public final fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future; +} + +public abstract interface class gg/essential/api/utils/ShutdownHookUtil { + public abstract fun register (Ljava/lang/Runnable;)V + public abstract fun unregister (Ljava/lang/Runnable;)V +} + +public abstract interface class gg/essential/api/utils/TrustedHostsUtil { + public abstract fun addTrustedHost (Lgg/essential/api/utils/TrustedHostsUtil$TrustedHost;)V + public abstract fun getTrustedHostByID (Ljava/lang/String;)Lgg/essential/api/utils/TrustedHostsUtil$TrustedHost; + public abstract fun getTrustedHosts ()Ljava/util/Set; + public abstract fun removeTrustedHost (Ljava/lang/String;)V +} + +public final class gg/essential/api/utils/TrustedHostsUtil$TrustedHost { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/Set; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)Lgg/essential/api/utils/TrustedHostsUtil$TrustedHost; + public static synthetic fun copy$default (Lgg/essential/api/utils/TrustedHostsUtil$TrustedHost;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILjava/lang/Object;)Lgg/essential/api/utils/TrustedHostsUtil$TrustedHost; + public fun equals (Ljava/lang/Object;)Z + public final fun getDomains ()Ljava/util/Set; + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/WebUtil { + public static final field INSTANCE Lgg/essential/api/utils/WebUtil; + public static final fun downloadToFile (Ljava/lang/String;Ljava/io/File;Ljava/lang/String;)V + public static final fun fetchJSON (Ljava/lang/String;)Lgg/essential/api/utils/JsonHolder; + public static final fun fetchString (Ljava/lang/String;)Ljava/lang/String; + public static final fun getLOG ()Z + public static final fun setLOG (Z)V +} + +public final class gg/essential/api/utils/mojang/Model : java/lang/Enum { + public static final field ALEX Lgg/essential/api/utils/mojang/Model; + public static final field Companion Lgg/essential/api/utils/mojang/Model$Companion; + public static final field STEVE Lgg/essential/api/utils/mojang/Model; + public static final fun byType (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public static final fun byTypeOrDefault (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public static final fun byVariant (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public static final fun byVariantOrDefault (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getModel ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public final fun getVariant ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public static fun values ()[Lgg/essential/api/utils/mojang/Model; +} + +public final class gg/essential/api/utils/mojang/Model$Companion { + public final fun byType (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public final fun byTypeOrDefault (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public final fun byVariant (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; + public final fun byVariantOrDefault (Ljava/lang/String;)Lgg/essential/api/utils/mojang/Model; +} + +public abstract interface class gg/essential/api/utils/mojang/MojangAPI { + public abstract fun changeSkin (Ljava/lang/String;Ljava/util/UUID;Lgg/essential/api/utils/mojang/Model;Ljava/lang/String;)Lgg/essential/api/utils/mojang/SkinResponse; + public abstract fun getName (Ljava/util/UUID;)Ljava/util/concurrent/CompletableFuture; + public abstract fun getNameHistory (Ljava/util/UUID;)Ljava/util/List; + public abstract fun getProfile (Ljava/util/UUID;)Lgg/essential/api/utils/mojang/Profile; + public abstract fun getUUID (Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture; +} + +public final class gg/essential/api/utils/mojang/Name { + public fun (Ljava/lang/String;Ljava/lang/Long;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/Long; + public final fun copy (Ljava/lang/String;Ljava/lang/Long;)Lgg/essential/api/utils/mojang/Name; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/Name;Ljava/lang/String;Ljava/lang/Long;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/Name; + public fun equals (Ljava/lang/Object;)Z + public final fun getChangedToAt ()Ljava/lang/Long; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/mojang/Profile { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)Lgg/essential/api/utils/mojang/Profile; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/Profile;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/Profile; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getProperties ()Ljava/util/List; + public final fun getTextures ()Lgg/essential/api/utils/mojang/ProfileTextures; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/mojang/ProfileTextures { + public static final field Companion Lgg/essential/api/utils/mojang/ProfileTextures$Companion; + public fun (Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;Lgg/essential/api/utils/mojang/Textures;)V + public final fun component1 ()Ljava/lang/Long; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lgg/essential/api/utils/mojang/Textures; + public final fun copy (Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;Lgg/essential/api/utils/mojang/Textures;)Lgg/essential/api/utils/mojang/ProfileTextures; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/ProfileTextures;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;Lgg/essential/api/utils/mojang/Textures;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/ProfileTextures; + public fun equals (Ljava/lang/Object;)Z + public final fun getProfileId ()Ljava/lang/String; + public final fun getProfileName ()Ljava/lang/String; + public final fun getTextures ()Lgg/essential/api/utils/mojang/Textures; + public final fun getTimestamp ()Ljava/lang/Long; + public fun hashCode ()I + public final fun toJson ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/mojang/ProfileTextures$Companion { + public final fun fromJson (Ljava/lang/String;)Lgg/essential/api/utils/mojang/ProfileTextures; +} + +public final class gg/essential/api/utils/mojang/Property { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lgg/essential/api/utils/mojang/Property; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/Property;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/Property; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/mojang/Skin { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lgg/essential/api/utils/mojang/Skin; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/Skin;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/Skin; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getState ()Ljava/lang/String; + public final fun getUrl ()Ljava/lang/String; + public final fun getVariant ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/mojang/SkinResponse { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lgg/essential/api/utils/mojang/SkinResponse; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/SkinResponse;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/SkinResponse; + public fun equals (Ljava/lang/Object;)Z + public final fun getCapes ()Ljava/util/List; + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getSkins ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/mojang/TextureURL { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lgg/essential/api/utils/mojang/TextureURL; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/TextureURL;Ljava/lang/String;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/TextureURL; + public fun equals (Ljava/lang/Object;)Z + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class gg/essential/api/utils/mojang/Textures { + public fun (Lgg/essential/api/utils/mojang/TextureURL;Lgg/essential/api/utils/mojang/TextureURL;)V + public final fun component1 ()Lgg/essential/api/utils/mojang/TextureURL; + public final fun component2 ()Lgg/essential/api/utils/mojang/TextureURL; + public final fun copy (Lgg/essential/api/utils/mojang/TextureURL;Lgg/essential/api/utils/mojang/TextureURL;)Lgg/essential/api/utils/mojang/Textures; + public static synthetic fun copy$default (Lgg/essential/api/utils/mojang/Textures;Lgg/essential/api/utils/mojang/TextureURL;Lgg/essential/api/utils/mojang/TextureURL;ILjava/lang/Object;)Lgg/essential/api/utils/mojang/Textures; + public fun equals (Ljava/lang/Object;)Z + public final fun getCape ()Lgg/essential/api/utils/mojang/TextureURL; + public final fun getSkin ()Lgg/essential/api/utils/mojang/TextureURL; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..6b1ddb3 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.* +import gg.essential.gradle.util.* + +plugins { + id("kotlin") + id("org.jetbrains.dokka") + id("gg.essential.defaults") + id("gg.essential.defaults.maven-publish") + id("gg.essential.multi-version") +} + +val mcVersionStr = platform.mcVersionStr +val mcPlatform = platform.loaderStr + +group = "gg.essential" +base.archivesName.set("EssentialAPI " + project.name) +tasks.compileKotlin { kotlinOptions.moduleName = "essential-api" } +java.withSourcesJar() +loom.noRunConfigs() // can't run just the API, only the implementation +configureDokkaForEssentialApi() + +// We need to use the compatibility mode on old versions because we used to use the old Kotlin defaults for those +tasks.compileKotlin.setJvmDefault(if (platform.mcVersion >= 11400) "all" else "all-compatibility") + +repositories { + mavenLocal() +} + +dependencies { + // Kotlin + val kotlin = platform.kotlinVersion + api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin.stdlib}") + api("org.jetbrains.kotlin:kotlin-reflect:${kotlin.stdlib}") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlin.coroutines}") + api("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:${kotlin.coroutines}") + api("org.jetbrains.kotlinx:kotlinx-serialization-core:${kotlin.serialization}") + api("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlin.serialization}") + compileOnly("org.jetbrains:annotations:23.0.0") + + // Core Libraries + api("org.kodein.di:kodein-di-jvm:7.6.0") + + // Due to loom's remapping, Gradle may not always select the latest version and multiple fabric-loader versions can + // end up on the dev classpath. + configurations.modApi.configure { exclude(group = "net.fabricmc", module = "fabric-loader") } + + // Core Gui Libraries + val ucMcVersion = when (platform.mcVersion) { + 11900, 11902, 11903, 11904, 12000, 12001, 12002, 12004, 12006 -> mcVersionStr.also { + // Elementa and Vigilance 1.18.1 are good enough for MC 1.19 so we only update UC. + // We do need to exclude the tranitive 1.18 UC though. + configurations.modApi.configure { exclude("gg.essential", "universalcraft-1.18.1-${platform.loaderStr}") } + } + 11802 -> "1.18.1" + else -> mcVersionStr + } + val libMcVersion = if (platform.mcVersion >= 11802) "1.18.1" else mcVersionStr + // These versions are configured in gradle/libs.versions.toml + modApi("gg.essential:vigilance-${libMcVersion}-${mcPlatform}:${libs.versions.vigilance.get()}") { exclude(group = "org.jetbrains.kotlin") } + modApi("gg.essential:universalcraft-${ucMcVersion}-${mcPlatform}:${libs.versions.universalcraft.get()}") { exclude(group = "org.jetbrains.kotlin") } + modApi("gg.essential:elementa-${libMcVersion}-${mcPlatform}:${libs.versions.elementa.get()}") { exclude(group = "org.jetbrains.kotlin") } + + // Miscellaneous Utility Libraries + api("com.github.videogame-hacker:Koffee:88ba1b0") { + exclude(module = "asm-commons") + exclude(module = "asm-tree") + exclude(module = "asm") + } + api("gg.essential.lib:caffeine:2.9.0") // keep in sync with `/libs/build.gradle.kts` + api("gg.essential.lib:mixinextras:${libs.versions.mixinextras.get()}") +} diff --git a/api/gradle.properties b/api/gradle.properties new file mode 100644 index 0000000..c728e71 --- /dev/null +++ b/api/gradle.properties @@ -0,0 +1 @@ +baseArtifactId=essential diff --git a/api/root.gradle.kts b/api/root.gradle.kts new file mode 100644 index 0000000..b01725a --- /dev/null +++ b/api/root.gradle.kts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.* + +plugins { + id("gg.essential.multi-version.root") + id("gg.essential.multi-version.api-validation") +} + +version = project.parent!!.version + +configurePreprocessTree(file("../versions")) + +apiValidation { + nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal") +} \ No newline at end of file diff --git a/api/src/main/java/gg/essential/api/utils/JsonHolder.java b/api/src/main/java/gg/essential/api/utils/JsonHolder.java new file mode 100644 index 0000000..20c280d --- /dev/null +++ b/api/src/main/java/gg/essential/api/utils/JsonHolder.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.List; + +/** + * Created by mitchellkatz on 10/10/19. Designed for production use in Sk1er Core + * + *

Nobody knows what this is.

+ */ +public class JsonHolder { + public static ThreadLocal printFormattingException = ThreadLocal.withInitial(() -> true); + private JsonObject object; + + public JsonHolder(JsonObject object) { + this.object = object; + } + + @SuppressWarnings("deprecation") + public JsonHolder(String raw) { + if (raw == null) + object = new JsonObject(); + else + try { + this.object = new JsonParser().parse(raw).getAsJsonObject(); + } catch (Exception e) { + this.object = new JsonObject(); + if (printFormattingException.get()) + e.printStackTrace(); + } + } + + public JsonHolder() { + this(new JsonObject()); + } + + public void ensureJsonHolder(String key) { + if (!has(key)) + put(key, new JsonHolder()); + } + + public void ensureJsonArray(String key) { + if (!has(key)) put(key, new JsonArray()); + } + + public JsonHolder optOrCreateJsonHolder(String key) { + ensureJsonHolder(key); + return optJSONObject(key); + } + + public JsonArray optOrCreateJsonArray(String key) { + ensureJsonArray(key); + return optJSONArray(key); + } + + @Override + public String toString() { + if (object != null) + return object.toString(); + return "{}"; + } + + public JsonHolder put(String key, boolean value) { + object.addProperty(key, value); + return this; + } + + public void mergeNotOverride(JsonHolder merge) { + merge(merge, false); + } + + public void mergeOverride(JsonHolder merge) { + merge(merge, true); + } + + public void merge(JsonHolder merge, boolean override) { + JsonObject object = merge.getObject(); + merge.getKeys().stream().filter(s -> override || !this.has(s)).forEach(s -> put(s, object.get(s))); + } + + private JsonHolder put(String s, JsonElement element) { + this.object.add(s, element); + return this; + } + + public JsonHolder put(String key, String value) { + object.addProperty(key, value); + return this; + } + + public JsonHolder put(String key, int value) { + object.addProperty(key, value); + return this; + } + + public JsonHolder put(String key, double value) { + object.addProperty(key, value); + return this; + } + + public JsonHolder put(String key, long value) { + object.addProperty(key, value); + return this; + } + + private JsonHolder defaultOptJSONObject(String key, JsonObject fallBack) { + try { + return new JsonHolder(object.get(key).getAsJsonObject()); + } catch (Exception e) { + return new JsonHolder(fallBack); + } + } + + public JsonArray defaultOptJSONArray(String key, JsonArray fallback) { + try { + return object.get(key).getAsJsonArray(); + } catch (Exception e) { + return fallback; + } + } + + public JsonArray optJSONArray(String key) { + return defaultOptJSONArray(key, new JsonArray()); + } + + + public boolean has(String key) { + return object.has(key); + } + + public long optLong(String key, long fallback) { + try { + JsonElement jsonElement = object.get(key); + if (jsonElement != null) + return jsonElement.getAsLong(); + } catch (Exception ignored) { + } + return fallback; + } + + public long optLong(String key) { + return optLong(key, 0); + } + + public boolean optBoolean(String key, boolean fallback) { + try { + JsonElement jsonElement = object.get(key); + if (jsonElement != null) + return jsonElement.getAsBoolean(); + } catch (Exception ignored) { + } + return fallback; + } + + public boolean optBoolean(String key) { + return optBoolean(key, false); + } + + public JsonObject optActualJSONObject(String key) { + try { + return object.get(key).getAsJsonObject(); + } catch (Exception e) { + return new JsonObject(); + } + } + + public JsonHolder optJSONObject(String key) { + return defaultOptJSONObject(key, new JsonObject()); + } + + + public int optInt(String key, int fallBack) { + try { + return object.get(key).getAsInt(); + } catch (Exception e) { + return fallBack; + } + } + + public int optInt(String key) { + return optInt(key, 0); + } + + + public String defaultOptString(String key, String fallBack) { + try { + JsonElement jsonElement = object.get(key); + if (jsonElement != null) + return jsonElement.getAsString(); + } catch (Exception ignored) { + } + return fallBack; + } + + public String optString(String key) { + return defaultOptString(key, ""); + } + + + public double optDouble(String key, double fallBack) { + try { + JsonElement jsonElement = object.get(key); + if (jsonElement != null) { + return jsonElement.getAsDouble(); + } + } catch (Exception ignored) { + } + return fallBack; + } + + public List getKeys() { + List tmp = new ArrayList<>(); + object.entrySet().forEach(e -> tmp.add(e.getKey())); + return tmp; + } + + public double optDouble(String key) { + return optDouble(key, 0.0); + } + + public int getSize() { + return object.entrySet().size(); + } + + public JsonObject getObject() { + return object; + } + + public boolean isNull(String key) { + return object.has(key) && object.get(key).isJsonNull(); + } + + public JsonHolder put(String values, JsonHolder values1) { + return put(values, values1.getObject()); + } + + public JsonHolder put(String values, JsonObject object) { + this.object.add(values, object); + return this; + } + + public JsonHolder put(String key, JsonArray jsonElements) { + this.object.add(key, jsonElements); + return this; + } + + public void remove(String header) { + object.remove(header); + } + + public JsonElement removeAndGet(String header) { + return object.remove(header); + } +} diff --git a/api/src/main/kotlin/gg/essential/api/DI.kt b/api/src/main/kotlin/gg/essential/api/DI.kt new file mode 100644 index 0000000..a82cfff --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/DI.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api + +import gg.essential.api.utils.essentialDI +import org.kodein.di.DIAware + +/** + * Essential uses Dependency Injection, or DI for short, to provide instances of its API interfaces. The library + * [Kodein](https://kodein.org/di/) is used to power our DI system, so feel free to read their documentation + * to familiarize yourself with its usage, though simple usage is explained below. + * + * If you wish to access these instances via DI rather than the [EssentialAPI] getters, you can use the + * [gg.essential.api.utils.get] function from Kotlin like so: `val hud = get()`. + * + * If you wish to customize Essential's DI by making your own types available and usable in your project, use [addModule]. + */ +abstract class DI : DIAware { + /** + * To add your own types to Essential's DI engine, simply provide a [org.kodein.di.DI.Module] instance. + * You can find information about creating these modules in their + * [documentation](https://docs.kodein.org/kodein-di/7.6/core/modules-inheritance.html). + */ + abstract fun addModule(module: org.kodein.di.DI.Module) + + protected fun init() { + essentialDI = this + } +} diff --git a/api/src/main/kotlin/gg/essential/api/EssentialAPI.kt b/api/src/main/kotlin/gg/essential/api/EssentialAPI.kt new file mode 100644 index 0000000..b9b6c26 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/EssentialAPI.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api + +import gg.essential.api.commands.CommandRegistry +import gg.essential.api.config.EssentialConfig +import gg.essential.api.data.OnboardingData +import gg.essential.api.gui.EssentialComponentFactory +import gg.essential.api.gui.Notifications +import gg.essential.api.utils.* +import gg.essential.api.utils.mojang.MojangAPI +import gg.essential.elementa.components.image.ImageCache + +/** + * The central access point for all public Essential development tools. Certain API interfaces will have static + * members for quick access, but they are all just "aliases" for the methods below. You can obtain an instance of + * [EssentialAPI] via either [EssentialAPI.getInstance] or dependency injection (read more here [DI]). + */ +interface EssentialAPI { + /** + * The entry point to Essential's powerful Command API. All commands must be registered here if you wish for + * them to work. + */ + fun commandRegistry(): CommandRegistry + + /** + * As said before, Essential provides the option of obtaining all of it's APIs via dependency injection (DI), as well + * as providing a library for you to use DI in your own projects. Read more about Essential's DI system in [DI]. + */ + fun di(): DI + + /** + * Notifications are a way to quickly display relevant information to the user without cluttering their chat + * box, so Essential provides an easy to use API to display beautiful notifications. + */ + fun notifications(): Notifications + + /** + * Essential has some internal settings that players can modify with the Essential config GUI, and if you wish + * to have behavior dependent on any of these options, you can access their values here. + */ + fun config(): EssentialConfig + + + /** + * A collection of GUI utilities. + */ + fun guiUtil(): GuiUtil + + /** + * A collection of general Minecraft related utilities. + */ + fun minecraftUtil(): MinecraftUtils + + /** + * A utility that allows you run something when shutting down to prevent using [Runtime]'s shutdown hook. + */ + fun shutdownHookUtil(): ShutdownHookUtil + + /** + * Image cache for Minecraft skins. + */ + fun imageCache(): ImageCache + + /** + * Utility for interacting with Essential's trusted image host list. + */ + fun trustedHostsUtil(): TrustedHostsUtil + + /** + * Utility for using some of Essential's [Elementa](https://github.com/sk1erllc/elementa) components + * in your guis. + */ + fun componentFactory(): EssentialComponentFactory + + /** + * Utility for interacting with the [Mojang API](https://wiki.vg/Mojang_API). + */ + fun mojangAPI(): MojangAPI + + /** + * Utility for accessing the player's Essential TOS status. + */ + fun onboardingData(): OnboardingData + + companion object { + private val instance: EssentialAPI = get() + + @JvmStatic + fun getInstance(): EssentialAPI = instance + + @JvmStatic + fun getCommandRegistry(): CommandRegistry = instance.commandRegistry() + + @JvmStatic + fun getDI(): DI = instance.di() + + @JvmStatic + fun getNotifications(): Notifications = instance.notifications() + + @JvmStatic + fun getConfig(): EssentialConfig = instance.config() + + @JvmStatic + fun getGuiUtil(): GuiUtil = instance.guiUtil() + + @JvmStatic + fun getMinecraftUtil(): MinecraftUtils = instance.minecraftUtil() + + @JvmStatic + fun getShutdownHookUtil() = instance.shutdownHookUtil() + + @JvmStatic + fun getImageCache(): ImageCache = instance.imageCache() + + @JvmStatic + fun getTrustedHostsUtil(): TrustedHostsUtil = instance.trustedHostsUtil() + + @JvmStatic + fun getEssentialComponentFactory(): EssentialComponentFactory = instance.componentFactory() + + @JvmStatic + fun getMojangAPI(): MojangAPI = instance.mojangAPI() + + @JvmStatic + fun getOnboardingData(): OnboardingData = instance.onboardingData() + } +} diff --git a/api/src/main/kotlin/gg/essential/api/commands/ArgumentParser.kt b/api/src/main/kotlin/gg/essential/api/commands/ArgumentParser.kt new file mode 100644 index 0000000..09f1d0b --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/commands/ArgumentParser.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.commands + +import java.lang.reflect.Parameter + +/** + * Defines how to parse a certain data type using the available command parameters. + * + * Register using a [CommandRegistry] + */ +interface ArgumentParser { + /** + * Parses to a certain type based on the arguments provided by the user + * and the parameter (for accessing annotations). + * + * If the arguments provided do not allow for your custom type to be created, throw an + * Exception, or return null. + */ + @Throws(Exception::class) + fun parse(arguments: ArgumentQueue, param: Parameter): T? + + /** + * Allows this ArgumentParser to provide custom tab completion options. + * + * This does not need to be overridden: by default no tab completion options will be available. + */ + fun complete(arguments: ArgumentQueue, param: Parameter): List = emptyList() +} diff --git a/api/src/main/kotlin/gg/essential/api/commands/ArgumentQueue.kt b/api/src/main/kotlin/gg/essential/api/commands/ArgumentQueue.kt new file mode 100644 index 0000000..d01467b --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/commands/ArgumentQueue.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.commands + +interface ArgumentQueue { + /** + * Poll the argument queue, getting the next string in the queue, and removing it from the queue. + * This method will throw an exception if there are no arguments left. + */ + fun poll(): String + + /** + * Peek into the argument queue without removing it. If there are no arguments left, the result will be null. + */ + fun peek(): String? + + /** + * Whether any more arguments have been passed. This is equivalent to [peek] returning null. + * + * @return true if no arguments are left + */ + fun isEmpty(): Boolean +} diff --git a/api/src/main/kotlin/gg/essential/api/commands/Command.kt b/api/src/main/kotlin/gg/essential/api/commands/Command.kt new file mode 100644 index 0000000..50f6d0c --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/commands/Command.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.commands + +import gg.essential.api.EssentialAPI +import gg.essential.api.utils.Multithreading +import gg.essential.api.utils.SerialExecutor +import java.lang.reflect.Method +import java.lang.reflect.Parameter +import java.util.* +import kotlin.coroutines.Continuation +import kotlin.reflect.KParameter +import kotlin.reflect.jvm.kotlinFunction + +/** + * This is the meat and potatoes of Essential's Command API. To create a custom command, simply create a class, + * or preferably object, that implements this class. The main selling point of this command API is that all command + * arguments are automatically described by the parameters to your handler functions. + * + * To begin, if you want to perform an action when a user invokes your command with no arguments + * (or if you have no subcommands), you must specify a function to handle this situation by annotating it with + * [DefaultHandler] like such: + * ```kt + * @DefaultHandler + * fun handle() + * ``` + * This function will be automatically located by its annotation and invoked at the right time. Read more about default + * handlers [here][DefaultHandler]. Note: The body of this function isn't specified, but is obviously required. + * + * The Command API also provides a way of specifying subcommands, such as `/$name $subcommand`. To + * create one of these, annotate a function with [SubCommand], like such: + * ```kt + * @Subcommand("$subcommand") + * fun subcommandHandler() + * ``` + * where `$subcommand` is replaced with the relevant subcommand name. + * + * However, the most important part of the API: command arguments. Oftentimes you will want the user to provide + * custom values to your handlers, so you can take specific action. To do so, simply specify parameters in your handler + * functions where their type is the desired type of command argument. If I wished to create a handler that accepted + * an integer as well as optionally a boolean, I could specify it like so in Kotlin: + * ```kt + * fun handle(number: Int, choice: Boolean?) + * ``` + * or like so in Java: + * ```java + * void handle(int number, @Nullable boolean choice) + * ``` + * Note: You could also wrap a type in `Optional` rather than making it nullable, and that will be handled correctly. + * Additionally, note that there are no annotations on these functions, they were omitted for the sake of brevity, you + * must still use [DefaultHandler] or [SubCommand] to make them recognized by the engine. + * + * With the handler above, a user providing the arguments `"5"` will invoke the function with the arguments (5, null). + * If the user provided the arguments `"5 false"`, the function will receive the arguments (5, false), as expected. + * If the user provided the arguments `"ee true"`, the function will not be invoked, and information on how to use + * the command will be printed like so: `Usage: /$command [choice]`. If your build wipes parameter names + * or you wish to provide custom names, you can additionally annotate parameters with the [DisplayName] annotation. + * + * If your parameter takes a String type, look into all the annotations provided to configure String parameters, + * such as [Greedy] or [Quotable]. + * + * @param name the name of the command, so your command begins as such: `/$name arguments...` + * @param autoHelpSubcommand whether to automatically generate a help subcommand (i.e. `/$name help`) that + * shows all the available subcommands, their usages, and their descriptions. + * @param hideFromAutocomplete whether to hide this command from Minecraft's command tab completion. + */ +abstract class Command @JvmOverloads constructor( + val name: String, + val autoHelpSubcommand: Boolean = true, + val hideFromAutocomplete: Boolean = false +) { + /** + * Global aliases for the command. + */ + open val commandAliases: Set? = emptySet() + + val defaultHandler = generateSequence>(this::class.java) { + it.superclass + }.flatMap, Method> { it.declaredMethods.toList() }.find { it.isAnnotationPresent(DefaultHandler::class.java) }?.let(::Handler) + + val uniqueSubCommands = this::class.java.declaredMethods.filter { it.isAnnotationPresent(SubCommand::class.java) } + .map { Handler(it) to it.getAnnotation(SubCommand::class.java) } + + val subCommands = uniqueSubCommands.let { list -> + val map = mutableMapOf() + list.forEach { (handler, ann) -> + map[ann.value.lowercase(Locale.ENGLISH)] = handler + ann.aliases.forEach { alias -> map[alias.lowercase(Locale.ENGLISH)] = handler } + } + map + } + + /** + * Registers this command with the Essential command system. + */ + fun register() { + EssentialAPI.getCommandRegistry().registerCommand(this) + } + + /** + * @param alias command alias. + * @param hideFromAutocomplete whether the alias will be hidden from tab complete + */ + data class Alias @JvmOverloads constructor(val alias: String, val hideFromAutocomplete: Boolean = false) + + class Handler(val method: Method) { + val params: Array = method.parameters.filter { it.type != Continuation::class.java }.toTypedArray() + // KReflect is slow to initialize, so we warm up its cache on a background thread + val kParams by lazy { + method.kotlinFunction?.parameters?.filter { it.kind == KParameter.Kind.VALUE } + }.also { + cacheCooker.execute { it.value } + } + + private companion object { + /** Warms the cache. One task at a time as to not flood the pool; concurrency won't help much anyway. */ + private val cacheCooker = SerialExecutor(Multithreading.scheduledPool) + } + } +} diff --git a/api/src/main/kotlin/gg/essential/api/commands/CommandRegistry.kt b/api/src/main/kotlin/gg/essential/api/commands/CommandRegistry.kt new file mode 100644 index 0000000..b80af0b --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/commands/CommandRegistry.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.commands + +/** + * This is where you must register all of the [Command] instances you create so Essential can provide them to + * the user. + */ +interface CommandRegistry { + /** + * Add your command instance to the command registry, making it available for use. + */ + fun registerCommand(command: Command) + + /** + * If one of your command handlers wishes to take a custom type, you must provide a way for the command engine + * to turn an [ArgumentQueue] into your instance. This is done via an implementation of [ArgumentParser]. + * + * There is no need to create custom parsers for default types such as: strings, integers, doubles, and booleans. + */ + fun registerParser(type: Class, parser: ArgumentParser) + + /** + * Remove your command from the command registry. + */ + fun unregisterCommand(command: Command) +} diff --git a/api/src/main/kotlin/gg/essential/api/commands/annotations.kt b/api/src/main/kotlin/gg/essential/api/commands/annotations.kt new file mode 100644 index 0000000..86c6fc2 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/commands/annotations.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.commands + +/** + * Marks the annotated function as a subcommand of the [Command] + * the function resides in. + * + * This is mostly a shortcut for using the [Options] annotation + a switch, although note it is only + * for the first sub-level of command. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class SubCommand(val value: String, val aliases: Array = [], val description: String = "") + +/** + * Marks the annotated function as the default handler of the [Command] + * the function resides in. That means if the Command has no [SubCommand]s, or the user didn't specify one of those + * subcommands, this function will be invoked instead. + * + * If no DefaultHandler is specified, the Command's usage will be printed instead. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class DefaultHandler + +/** + * Provides a custom display name for this parameter. This is used when printing a command's usage. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class DisplayName(val value: String) + +/** + * Only allows the given list of strings to be passed to the annotated argument. + * + * This is most useful for nested subcommands. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Options(val value: Array) + +/** + * Marks this parameter as greedy and should take up the rest of the available command. + * This is primarily meant for the last parameter in a function, as anything after it will be left without + * a value. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Greedy + +/** + * Indicates that this parameter should take [value] words of the command. + * + * If [allowLess] is set to true, anything from 1 to [value] words are allowed, + * however if it is set to false an error will be thrown and the usage message + * will be displayed, which should indicate to the user the amount of words + * needed. + * + * If you need this specific amount of words to create a custom data type + * look into creating and registering an [ArgumentParser] instead. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Take(val value: Int, val allowLess: Boolean = true) + +/** + * Indicates that this string parameter can be quoted. + * + * This means that if the arguments passed in are + * /command "one two three" four + * and the first accepted parameter is [Quotable] then it will receive + * the string 'one two three'. + * + * If no quotes are present, then only the first word will be passed in. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Quotable diff --git a/api/src/main/kotlin/gg/essential/api/config/EssentialConfig.kt b/api/src/main/kotlin/gg/essential/api/config/EssentialConfig.kt new file mode 100644 index 0000000..bc7dae4 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/config/EssentialConfig.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.config + +/** + * Options from Essential's `Essential Settings` menu. + */ +interface EssentialConfig { + /** + * Show [Notifications] when a friend come online. + */ + var friendConnectionStatus: Boolean + + /** + * Show the option to open singleplayer worlds to friends. + */ + var openToFriends: Boolean + + /** + * Block all of Essential's [Notifications]. + */ + var disableAllNotifications: Boolean + + /** + * Show [Notifications] when the player receives a direct message. + */ + var messageReceivedNotifications: Boolean + + /** + * Show [Notifications] when the player receives a message from a group. + */ + var groupMessageReceivedNotifications: Boolean + + /** + * Play a sound when the player receives a message. + */ + var messageSound: Boolean + + /** + * Display a notification modal after Essential has updated. + */ + var updateModal: Boolean + + /** + * Show an icon in the tab list on players using Essential. + */ + var showEssentialIndicatorOnTab: Boolean + + /** + * Show an icon on the name tags of players using Essential. + */ + var showEssentialIndicatorOnNametag: Boolean + + /** + * Share the player's current server with friends. + */ + var sendServerUpdates: Boolean + + + /** + * The player's friend request privacy level. + * + * 0 = Anyone can send the player friend requests; + * 1 = Only friends of friends can send the player friend requests; + * 2 = Nobody can send the player friend requests. + */ + var friendRequestPrivacy: Int + + /** + * The multiplayer tab the player last used. + * + * 0 = Favourite servers; + * 1 = Servers with friends on them; + * 2 = Discover servers. + */ + var currentMultiplayerTab: Int + + /** + * Show the player a warning popup when they are using ModCore. + */ + var modCoreWarning: Boolean + + /** + * Show the player a confirmation modal before opening a link to a trusted host. + */ + var linkWarning: Boolean + + /** + * Use the cinematic camera when zooming. + */ + var zoomSmoothCamera: Boolean + + /** + * Animate zooming in. + */ + var smoothZoomAnimation: Boolean + + @Deprecated("Removed.") + var smoothZoomAlgorithm: Int + + /** + * Zoom key toggles zoom (instead of the player having to hold down the key). + */ + var toggleToZoom: Boolean + + /** + * Player's selected Essential "mode". + * + * True for Essential Full, false for Essential Mini. + */ + @Deprecated("This setting no longer has any effect") + var essentialFull: Boolean + + /** + * Choose the size of all Essential Menus. + */ + @Deprecated("This setting no longer has any effect, will now always be auto (0)") + var essentialGuiScale: Int + + /** + * Automatically refresh the active session if it's expired when connecting to a server. + */ + var autoRefreshSession: Boolean + + //#if MC<11400 + /** + * Use a borderless version of fullscreen. + */ + var windowedFullscreen: Boolean + //#endif + + /** + * Disable all [Notifications] and notification sounds. + */ + var streamerMode: Boolean + + @Deprecated("This setting no longer has any effect") + var discordSdk: Boolean + + /** + * Enables Discord Rich Presence. + */ + var discordRichPresence: Boolean + + /** + * Enables Discord's Ask To Join. + */ + var discordAllowAskToJoin: Boolean + + /** + * Shows the user's username and avatar on the rich presence + */ + var discordShowUsernameAndAvatar: Boolean + + /** + * Shows the server that the user is connected to on their rich presence + */ + var discordShowCurrentServer: Boolean + + /** + * When enabled and the server overrides a skin, all cosmetics will be hidden on that player + * This is for game-modes such as Hypixel murder mystery where having a suit equipped can lead to an advantage + */ + var hideCosmeticsWhenServerOverridesSkin: Boolean + + /** + * Enable Essential's screenshot manager. + */ + var essentialScreenshots: Boolean + + /** + * Play a sound when capturing a screenshot + */ + var screenshotSounds: Boolean + + + /** + * Whether the vanilla screenshot message is sent in chat on capture + */ + var enableVanillaScreenshotMessage: Boolean + + @Deprecated("This setting is no longer used") + var cosmeticArmorSetting: Int + + /** + * Choose between using 12 or 24 hour time for dates/timestamps. + * 0 = 12 hour time (03:00 AM, 03:00 PM) + * 1 = 24 hour time (03:00, 15:00) + */ + var timeFormat: Int + + @Deprecated( + message = "No longer used, replaced by essentialGuiScale.", + replaceWith = ReplaceWith("essentialGuiScale") + ) + var overrideGuiScale: Boolean +} diff --git a/api/src/main/kotlin/gg/essential/api/cosmetics/RenderCosmetic.kt b/api/src/main/kotlin/gg/essential/api/cosmetics/RenderCosmetic.kt new file mode 100644 index 0000000..ebe4c71 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/cosmetics/RenderCosmetic.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.cosmetics + +/** + * Marker interface which may be implemented by wearable (as in armor) items. + * When armor is equipped, Essential will normally hide cosmetics on the respective body part. However, if the equipped item implements this interface, cosmetics will continue to be rendered as if it wasn't equipped. + */ +interface RenderCosmetic {} \ No newline at end of file diff --git a/api/src/main/kotlin/gg/essential/api/data/OnboardingData.kt b/api/src/main/kotlin/gg/essential/api/data/OnboardingData.kt new file mode 100644 index 0000000..b8b012c --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/data/OnboardingData.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.data + +/** + * Utility for accessing the player's Essential TOS status. + */ +interface OnboardingData { + /** + * @return has the player accepted our TOS. + */ + fun hasAcceptedEssentialTOS(): Boolean + + /** + * @return has the player denied our TOS. + */ + fun hasDeniedEssentialTOS(): Boolean +} \ No newline at end of file diff --git a/api/src/main/kotlin/gg/essential/api/gui/EssentialGUI.kt b/api/src/main/kotlin/gg/essential/api/gui/EssentialGUI.kt new file mode 100644 index 0000000..b023711 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/gui/EssentialGUI.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.gui + +import gg.essential.api.EssentialAPI +import gg.essential.elementa.ElementaVersion +import gg.essential.elementa.UIComponent +import gg.essential.elementa.WindowScreen +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UIWrappedText +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.universal.USound +import gg.essential.vigilance.utils.onLeftClick +import org.jetbrains.annotations.ApiStatus +import java.awt.Color + +/** + * Basic [WindowScreen] using essential colors and sizing. + * Provides a great base for your mod's gui. + * + * @param guiTitle title of the Gui. + */ +@Suppress("unused") +open class EssentialGUI( + version: ElementaVersion, + guiTitle: String = "", + newGuiScale: Int = EssentialAPI.getGuiUtil().getGuiScale(), + restorePreviousGuiOnClose: Boolean = true, + /** + * Describes what Discord RP shows when the user is in this UI. + * + * Examples + * Wardrobe - Customizing their character + * ScreenshotBrowser - Browsing screenshots + * SocialMenu - Messaging friends + */ + val discordActivityDescription: String? = null, +) : WindowScreen( + version, + newGuiScale = newGuiScale, + restoreCurrentGuiOnClose = restorePreviousGuiOnClose +) { + @JvmOverloads + constructor( + version: ElementaVersion, + guiTitle: String = "", + newGuiScale: Int = EssentialAPI.getGuiUtil().getGuiScale(), + restorePreviousGuiOnClose: Boolean = true, + ) : this(version, guiTitle, newGuiScale, restorePreviousGuiOnClose, null) + + @Deprecated("Add ElementaVersion as the first argument to opt-in to improved behavior.") + @Suppress("DEPRECATION") + @JvmOverloads + constructor( + guiTitle: String = "", + newGuiScale: Int = EssentialAPI.getGuiUtil().getGuiScale(), + restorePreviousGuiOnClose: Boolean = true + ) + : this(ElementaVersion.V0, guiTitle, newGuiScale, restorePreviousGuiOnClose) + + var backButtonVisible = true + set(value) { + if (field != value) { + field = value + if (!value) { + backContainer.hide(instantly = true) + } else { + backContainer.unhide() + } + } + } + + // Not in the companion object because that field cannot be hidden from the API while being internal + @get:ApiStatus.Internal + protected val outlineThickness = 3f + + + private val background by UIBlock(BACKGROUND).constrain { + width = 100.percent + height = 100.percent + } childOf window + + val scissorBox by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 85.percent + height = 75.percent + } childOf window + + private val container by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 85.percent.coerceAtMost(100.percent - basicWidthConstraint { backContainer.getWidth() * 2 }).coerceAtLeast(0.pixels) + height = 75.percent + } childOf window + + private val leftDivider by UIBlock(DARK_GRAY).constrain { + x = SiblingConstraint(alignOpposite = true) + y = componentYConstraint(container) + width = outlineThickness.pixels + height = componentHeightConstraint(container) + } childOf window + + val rightDivider by UIBlock(DARK_GRAY).constrain { + x = SiblingConstraint() boundTo container + y = componentYConstraint(container) + width = outlineThickness.pixels + height = componentHeightConstraint(container) + } childOf window + + @get:ApiStatus.Internal + val bottomDivider by UIBlock(DARK_GRAY).constrain { + x = componentXConstraint(leftDivider) + y = SiblingConstraint() + width = + componentWidthConstraint(container) + (outlineThickness.pixels * 2) // 2* outlineThickness so the corners aren't missing + height = outlineThickness.pixels + } childOf window + + + val titleBar by UIBlock(DARK_GRAY).constrain { + width = 100.percent + height = 30.pixels + } childOf container + + val titleText by UIWrappedText(guiTitle).constrain { + x = 10.pixels + y = 11.pixels + color = TEXT_HIGHLIGHT.toConstraint() + } childOf titleBar + + val content by UIContainer().constrain { + y = SiblingConstraint() + width = RelativeConstraint() + height = FillConstraint() + } childOf container + + private val backContainer: UIComponent by EssentialAPI.getEssentialComponentFactory().buildIconButton { + iconResource = "/assets/essential/textures/arrow-left_5x7.png" + }.constrain { + x = (SiblingConstraint(18f, alignOpposite = true) boundTo leftDivider).coerceAtLeast(0.pixels) + y = CenterConstraint() boundTo titleBar + width = ChildBasedSizeConstraint() + 12.pixels + height = ChildBasedSizeConstraint() + 10.pixels + } childOf window + + + init { + // Notches in titlebar + UIBlock(COMPONENT_HIGHLIGHT).constrain { + x = 0.pixels(alignOutside = true) boundTo titleBar + y = 0.pixels boundTo titleBar + height = 100.percent boundTo titleBar + width = outlineThickness.pixels + } childOf window + + UIBlock(COMPONENT_HIGHLIGHT).constrain { + x = 0.pixels(alignOpposite = true, alignOutside = true) boundTo titleBar + y = 0.pixels boundTo titleBar + height = 100.percent boundTo titleBar + width = outlineThickness.pixels + } childOf window + + backContainer.onLeftClick { + backButtonPressed() + } + } + + + @ApiStatus.Internal + open fun backButtonPressed() { + USound.playButtonPress() + restorePreviousScreen() + } + + fun setTitle(newTitle: String) { + titleText.setText(newTitle) + } + private companion object EssentialGuiPalette { + private val BACKGROUND = Color(0x181818) + private val DARK_GRAY: Color = Color(0x232323) + private val BUTTON = Color(0x323232) + private val BUTTON_HIGHLIGHT = Color(0x474747) + private var TEXT = Color(0xBFBFBF) + private var TEXT_HIGHLIGHT = Color(0xE5E5E5) + private val COMPONENT_HIGHLIGHT = Color(0x303030) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/gg/essential/api/gui/GuiRequiresTOS.kt b/api/src/main/kotlin/gg/essential/api/gui/GuiRequiresTOS.kt new file mode 100644 index 0000000..2bc4265 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/gui/GuiRequiresTOS.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.gui + +/** + * Guis that implement this will not open unless the player has accepted our TOS. + * + * @see gg.essential.api.data.OnboardingData + */ +interface GuiRequiresTOS \ No newline at end of file diff --git a/api/src/main/kotlin/gg/essential/api/gui/Notifications.kt b/api/src/main/kotlin/gg/essential/api/gui/Notifications.kt new file mode 100644 index 0000000..c7ac573 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/gui/Notifications.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.gui + +import gg.essential.elementa.ElementaVersion +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.State +import java.util.* + +/** + * Simple API for notifying the user of some information. + * + * The notifications show up in the bottom-right in-game, sliding out from the right side of the screen, + * similarly to the Windows 10 notification style. Notifications last for approximately 4 seconds, unless + * hovered by the user, at which point that timer will stop until they move their mouse away from the notification. + * + * Notifications are styled with the [gg.essential.vigilance.gui.VigilancePalette] color scheme. + */ +interface Notifications { + /** + * Push a new, non-interactive notification with the given title and message. + * + * Since no callback is provided, the notification will simply go away when clicked, and no further + * action will be taken. + * + * @param title notification header + * @param message notification body + */ + fun push(title: String, message: String) + + /** + * Push a new notification with the given title and message. + * + * Further details may be configured via a [NotificationBuilder] by passing a [configure] function. + * + * @param title notification header + * @param message notification body + * @param configure a function to prepare a NotificationBuilder to modify the notification as desired + */ + @Suppress("INAPPLICABLE_JVM_NAME") // https://youtrack.jetbrains.com/issue/KT-31420 + @JvmName("push") // different name for kotlin to avoid overload ambiguity, kotlin can just use push with default args + fun pushWithBuilder(title: String, message: String, configure: NotificationBuilder.() -> Unit = {}) + + /** + * Push a new, non-interactive notification with the given title, message, and a customizable duration. + * + * Since no callback is provided, the notification will simply go away when clicked, and no further + * action will be taken. + * + * @param title notification header + * @param message notification body + * @param duration how long in seconds the notification will stay on screen + */ + fun push(title: String, message: String, duration: Float = 4f) + + /** + * Push a new notification with the given title and message, as well as [action] which will be invoked + * when the user clicks on the notification. This can be used for all sorts of purposes, as it is generally useful + * to respond with some type of action when the user clicks a notification. + * + * @param title notification header + * @param message notification body + * @param action ran when the player clicks the notification + */ + @Suppress("INAPPLICABLE_JVM_NAME") // https://youtrack.jetbrains.com/issue/KT-31420 + @JvmName("push") // different name for kotlin to avoid overload ambiguity, kotlin can just use push with default args + fun pushWithAction(title: String, message: String, action: () -> Unit = {}) + + /** + * Push a new notification with the given title, message, customizable duration, as well as [action] which will be invoked + * when the user clicks on the notification. This can be used for all sorts of purposes, as it is generally useful + * to respond with some type of action when the user clicks a notification. + * + * @param title notification header + * @param message notification body + * @param duration how long in seconds the notification will stay on screen + * @param action ran when the player clicks the notification + */ + @Suppress("INAPPLICABLE_JVM_NAME") // https://youtrack.jetbrains.com/issue/KT-31420 + @JvmName("push") // different name for kotlin to avoid overload ambiguity, kotlin can just use push with default args + fun pushWithDurationAndAction(title: String, message: String, duration: Float = 4f, action: () -> Unit = {}) + + /** + * Push a new notification with the given title, message, customizable duration, [action] which will be invoked + * when the user clicks on the notification, and [close] which will be invoked when the notification has expired. + * This can be used for all sorts of purposes, as it is generally useful + * to respond with some type of action when the user clicks a notification. + * + * @param title notification header + * @param message notification body + * @param duration how long in seconds the notification will stay on screen + * @param action ran when the player clicks the notification + * @param close ran when the notification has expired + */ + @Suppress("INAPPLICABLE_JVM_NAME") // https://youtrack.jetbrains.com/issue/KT-31420 + @JvmName("push") // different name for kotlin to avoid overload ambiguity, kotlin can just use push with default args + fun pushWithDurationActionAndClose(title: String, message: String, duration: Float = 4f, action: () -> Unit = {}, close: () -> Unit = {}) + + /** + * Push a new notification with the given title, message, customizable duration, [action] which will be invoked + * when the user clicks on the notification, and [close] which will be invoked when the notification has expired. + * This can be used for all sorts of purposes, as it is generally useful + * to respond with some type of action when the user clicks a notification. + * + * @param title notification header + * @param message notification body + * @param duration how long in seconds the notification will stay on screen + * @param action ran when the player clicks the notification + * @param close ran when the notification has expired + * @param configure a function to prepare a NotificationBuilder to modify the notification as desired + */ + fun push( + title: String, + message: String, + duration: Float = 4f, + action: () -> Unit = {}, + close: () -> Unit = {}, + configure: NotificationBuilder.() -> Unit = {} + ) +} + +interface NotificationBuilder { + /** + * How long in seconds the notification will stay on screen. + * Default value is 4 seconds. + */ + var duration: Float + + /** + * Callback to be invoked when the user clicks on the notification. + * This can be used for all sorts of purposes, as it is generally useful to respond with some type of action when + * the user clicks a notification. + */ + var onAction: () -> Unit + + /** + * Callback to be invoked once the notification is closed. + * This will be invoked even when [action] was already invoked (in such cases, [action] will be invoked first). + */ + var onClose: () -> Unit + + var elementaVersion: ElementaVersion + + /** + * State controlling whether the notification timer is progressing or not + */ + var timerEnabled: State + + /** + * Whether the notification's title should be cut off at the first line or not + */ + var trimTitle: Boolean + + /** + * Whether the notification's message should be cut off at 3 lines or not + */ + var trimMessage: Boolean + + /** + * The color to be used for the headline of the notification. + */ + var type: NotificationType + + /** + * The unique id of the notification, used to prevent duplicates by disregarding new notifications while + * the current notification is still active. + * + * The given object must have a correct and stable `hashCode` and `equals` implementation, using which it is compared to other ids. + * + * Hint: A plain `static Object MY_ID = new Object();` (or for Kotlin a plain `object MyId`) does fulfill these requirements + * and as such makes for a great unique id for most simple cases. + * + * Hint: An even simpler option (provided you only need it in a single place) is using an anonymous class: + * `new Object(){}.getClass()` (or for Kotlin: `object {}.javaClass`). Note that you need to pass the anonymous class, + * which is a singleton; you must not just pass an instance of that class, because that will be a different Object on each invocation. + * + * Note that if you use a String (or any other public type) as the id, that String must be unique across all mods. + * The recommended way to guarantee this is to prefix your string with your mod id, separated with a colon, + * e.g. `mymodid:mynotification`, or to wrap it into a custom `data class` which only your mod uses. + */ + var uniqueId: Any? + + /** + * A function that dismisses the notification when called. The notification will animate out and then be removed. + */ + val dismissNotification: () -> Unit + + /** + * A function that dismisses the notification instantly when called. The notification will be removed without animating out. + */ + val dismissNotificationInstantly: () -> Unit + + fun withElementaVersion(version: ElementaVersion) = apply { this.elementaVersion = version } + + fun withCustomComponent(slot: Slot, component: UIComponent): NotificationBuilder +} + +enum class Slot { + ACTION, + LARGE_PREVIEW, + SMALL_PREVIEW, + + @Deprecated("Replaced by `ICON` (same position as `PREVIEW`; meant for small icons) and `SMALL_PREVIEW` (similar position as `ACTION`; meant for small to mid sized previews)") + PREVIEW, + + ICON, +} + +enum class NotificationType { + GENERAL, + INFO, + WARNING, + ERROR, + DISCORD, +} diff --git a/api/src/main/kotlin/gg/essential/api/gui/essentialComponentFactory.kt b/api/src/main/kotlin/gg/essential/api/gui/essentialComponentFactory.kt new file mode 100644 index 0000000..2524162 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/gui/essentialComponentFactory.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.gui + +import com.mojang.authlib.GameProfile +import gg.essential.api.EssentialAPI +import gg.essential.api.profile.WrappedGameProfile +import gg.essential.api.profile.wrapped +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import org.jetbrains.annotations.ApiStatus +import kotlin.reflect.KProperty + +/** + * Utility for using some of Essential's [Elementa](https://github.com/sk1erllc/elementa) components + * in your mod's guis. + */ +interface EssentialComponentFactory { + /** + * Build an emulated player component like the one seen in Essential's character customizer gui. + * + * @param showCape render the player's cape + * @param draggable allow the player's angle to be moved with the mouse + * @return emulated player component + */ + @Deprecated( + "Does not support all options.", + replaceWith = ReplaceWith( + "this.buildEmulatedPlayer {\nthis@buildEmulatedPlayer.showCape = showCape\nthis@buildEmulatedPlayer.draggable = draggable }", + "gg.essential.api.gui.buildEmulatedPlayer", + ) + ) + fun buildEmulatedPlayer(showCape: Boolean = true, draggable: Boolean = true): UIComponent = buildEmulatedPlayer { + this.showCape = showCape + this.draggable = draggable + } + + /** + * Build an emulated player component like the one seen in Essential's character customizer gui + * using values from an [EmulatedPlayerBuilder]. + * + * @return emulated player component + */ + fun build(builder: EmulatedPlayerBuilder): UIComponent + + /** + * Build a confirmation model component like the ones seen in Essential's friends gui + * using values from a [ConfirmationModalBuilder]. + */ + fun build(builder: ConfirmationModalBuilder): UIComponent + + @ApiStatus.Internal + fun build(builder: IconButtonBuilder): UIComponent +} + +class EmulatedPlayerBuilder { + + /** + * [GameProfile] of player to be emulated. + */ + @Deprecated( + "GameProfile does not have a correct `equals` or `hashCode` implementation.", + replaceWith = ReplaceWith("wrappedProfileState") + ) + var profileState: State = BasicState(null) + + /** + * [GameProfile] of player to be emulated. + */ + var profile: GameProfile? + get() = wrappedProfileState?.get()?.profile ?: profileState.get() + set(value) { + wrappedProfileState?.set(value?.wrapped()) + + // `set` won't do anything if `oldValue == value`, and therefore `get` will continue returning `oldValue`. + // To avoid this, we must set `profileState` to a different value before setting the new value. + if (profileState.get() == value && profileState.get()?.wrapped() != profile?.wrapped()) { + profileState.set(null) // workaround for GameProfile having broken equals implementation + } + profileState.set(value) + } + + /** + * [WrappedGameProfile] of player to be emulated. + */ + var wrappedProfileState: State? = null + + /** + * Show cape (if present) on emulated player. + */ + var showCapeState: State = BasicState(true) + + /** + * Show cape (if present) on emulated player. + */ + var showCape: Boolean by showCapeState + + /** + * Allow the emulated player's angle to be moved with the mouse. + */ + var draggableState: State = BasicState(true) + + /** + * Allow the emulated player's angle to be moved with the mouse. + */ + var draggable: Boolean by draggableState + + /** + * Render name tag on emulated player + */ + var renderNameTagState: State = BasicState(false) + + /** + * Render name tag on emulated player + */ + var renderNameTag: Boolean by renderNameTagState + + @JvmOverloads + fun build(factory: EssentialComponentFactory = EssentialAPI.getEssentialComponentFactory()): UIComponent = + factory.build(this) +} +@ApiStatus.Internal +class IconButtonBuilder { + + var iconResourceState: State = BasicState("") + var tooltipTextState: State = BasicState("") + var enabledState: State = BasicState(true) + var buttonTextState: State = BasicState("") + var iconShadowState: State = BasicState(true) + var textShadowState: State = BasicState(true) + + var iconResource: String by iconResourceState + var tooltipText: String by tooltipTextState + var enabled: Boolean by enabledState + var buttonText: String by buttonTextState + var iconShadow: Boolean by iconShadowState + var textShadow: Boolean by textShadowState + var tooltipBelowComponent: Boolean = true + + @JvmOverloads + fun build(factory: EssentialComponentFactory = EssentialAPI.getEssentialComponentFactory()): UIComponent = + factory.build(this) +} + + + + +class ConfirmationModalBuilder { + /** + * Modal text. + */ + var text: String = "" + var secondaryText: String? = null + var inputPlaceholder: String? = null + var confirmButtonText: String = "Confirm" + var denyButtonText: String = "Decline" + + /** + * Ran when deny button is clicked. + */ + var onDeny: () -> Unit = {} + + /** + * Ran when confirm button is clicked. + */ + var onConfirm: (userInput: String) -> Unit = {} + + @JvmOverloads + fun build(factory: EssentialComponentFactory = EssentialAPI.getEssentialComponentFactory()): UIComponent = + factory.build(this) +} + +private operator fun State.setValue(obj: Any, property: KProperty<*>, t: T) = set(t) +private operator fun State.getValue(obj: Any, property: KProperty<*>): T = get() + +inline fun EssentialComponentFactory.buildEmulatedPlayer(block: EmulatedPlayerBuilder.() -> Unit): UIComponent = + EmulatedPlayerBuilder().apply(block).build(this) + +inline fun EssentialComponentFactory.buildConfirmationModal(block: ConfirmationModalBuilder.() -> Unit): UIComponent = + ConfirmationModalBuilder().apply(block).build(this) + +@ApiStatus.Internal +inline fun EssentialComponentFactory.buildIconButton(block: IconButtonBuilder.() -> Unit): UIComponent = + IconButtonBuilder().apply(block).build(this) + diff --git a/api/src/main/kotlin/gg/essential/api/profile/WrappedGameProfile.kt b/api/src/main/kotlin/gg/essential/api/profile/WrappedGameProfile.kt new file mode 100644 index 0000000..b5e42a5 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/profile/WrappedGameProfile.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.profile + +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.PropertyMap +import java.util.* + +/** + * A wrapper for [GameProfile] which correctly implements [equals] and [hashCode]. + */ +class WrappedGameProfile( + val profile: GameProfile +) { + constructor(id: UUID, name: String) : this(GameProfile(id, name)) + + val id: UUID + get() = profile.id ?: UUID.nameUUIDFromBytes("OfflinePlayer:$name".toByteArray()) + + val name: String + get() = profile.name ?: "" + + val properties: PropertyMap + get() = profile.properties + + fun copy(): WrappedGameProfile { + val profile = GameProfile(profile.id, profile.name) + profile.properties.putAll(properties) + + return WrappedGameProfile(profile) + } + + override fun hashCode(): Int { + var result = profile.id?.hashCode() ?: 0 + result = 31 * result + (profile.name?.hashCode() ?: 0) + result = 31 * result + profile.properties.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WrappedGameProfile) return false + + if (profile.id != other.profile.id) return false + if (profile.name != other.profile.name) return false + if (profile.properties != other.profile.properties) return false + + return true + } +} + +/** + * A simple extension function for wrapping a [GameProfile]. + * The constructor is fine to use too, but this can help make null-chaining profile conversions more readable. + */ +fun GameProfile.wrapped() = WrappedGameProfile(this) \ No newline at end of file diff --git a/api/src/main/kotlin/gg/essential/api/utils/GuiUtil.kt b/api/src/main/kotlin/gg/essential/api/utils/GuiUtil.kt new file mode 100644 index 0000000..41e2f9f --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/GuiUtil.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +import gg.essential.api.EssentialAPI +import net.minecraft.client.gui.GuiScreen + +/** + * A collection of simple & handy utility functions for interacting with Minecraft's GUI system. + */ +interface GuiUtil { + /** + * Queue a new screen for opening. This API will make sure the GUI will be displayed synchronously, + * avoiding any weird mouse glitches. + */ + fun openScreen(screen: GuiScreen?) + + /** + * @return the currently open screen, or null if none is opened + */ + fun openedScreen(): GuiScreen? + + /** + * @return -1 for current MC gui scale or positive integer indicating the GUI scale + */ + fun getGuiScale(): Int + + /** + * @return -1 for current MC gui scale or positive integer indicating the GUI scale + */ + fun getGuiScale(step: Int): Int + + companion object { + @JvmStatic + fun open(screen: GuiScreen?) = EssentialAPI.getGuiUtil().openScreen(screen) + + @JvmStatic + fun getOpenedScreen() = EssentialAPI.getGuiUtil().openedScreen() + } +} diff --git a/api/src/main/kotlin/gg/essential/api/utils/KotlinAdapter.kt b/api/src/main/kotlin/gg/essential/api/utils/KotlinAdapter.kt new file mode 100644 index 0000000..5bd83fd --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/KotlinAdapter.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +//#if FORGE && MC<=11202 +import net.minecraftforge.fml.common.FMLModContainer +import net.minecraftforge.fml.common.ILanguageAdapter +import net.minecraftforge.fml.common.ModContainer +import net.minecraftforge.fml.relauncher.Side +import java.lang.reflect.Field +import java.lang.reflect.Method + +/** + * This class is provided as a utility to allow Forge mods to use Forge's `@Mod` annotation in concert with a Kotlin + * `object`. Simply provide `modLanguageAdapter = "gg.essential.api.utils.KotlinAdapter"` as an argument in your + * `@Mod` annotation to make them play nicely. + */ +class KotlinAdapter : ILanguageAdapter { + override fun supportsStatics(): Boolean { + return false + } + + override fun setProxy(target: Field, proxyTarget: Class<*>, proxy: Any) { + target.set(proxyTarget.kotlin.objectInstance, proxy) + } + + override fun getNewInstance( + container: FMLModContainer, + objectClass: Class<*>, + classLoader: ClassLoader, + factoryMarkedAnnotation: Method? + ): Any { + return objectClass.kotlin.objectInstance ?: objectClass.newInstance() + } + + override fun setInternalProxies(mod: ModContainer?, side: Side?, loader: ClassLoader?) { } +} +//#endif diff --git a/api/src/main/kotlin/gg/essential/api/utils/MinecraftUtils.kt b/api/src/main/kotlin/gg/essential/api/utils/MinecraftUtils.kt new file mode 100644 index 0000000..5c6f63c --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/MinecraftUtils.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +import gg.essential.universal.wrappers.message.UTextComponent +import net.minecraft.util.ResourceLocation +import java.awt.image.BufferedImage + +/** + * Provides a collection of general Minecraft-related utility functions, so you don't have to copy and paste + * common functions into your mods. + */ +interface MinecraftUtils { + /** + * Queues a message component to be displayed to the player in chat, client-side only. + */ + fun sendMessage(message: UTextComponent) + + /** + * Queues a message to be displayed to the player in chat, client-side only. The input message is also + * translated/formatted with minecraft internationalization utility, I18n. + */ + fun sendChatMessageAndFormat(message: String) + + /** + * Queues a message to be displayed to the player in chat, client-side only. The input message is also + * translated/formatted with minecraft internationalization utility, I18n and the given parameters. + */ + fun sendChatMessageAndFormat(message: String, vararg parameters: Any) + + /** + * Queues a message to be displayed to the player in chat, client-side only. + * + * NOTE: This message is prefixed with `[Essential]`, and as such, should only be used to display Essential + * information. If you simply want to send a normal message, use the function here that takes a [UTextComponent]. + */ + fun sendMessage(message: String) + + /** + * Queues a message to be displayed to the player in chat, client-side only. The message is in the format: + * "$prefix&r$message" + * + * The input message is also translated/formatted with minecraft internationalization utility, I18n. + */ + fun sendMessage(prefix: String, message: String) + + /** + * @return whether the player is currently logged onto the Hypixel server + */ + fun isHypixel(): Boolean + + /** + * Loads the given ResourceLocation into memory as a BufferedImage, potentially for use in a DynamicTexture + * + * @return the image, or null if it failed to load + */ + fun getResourceImage(location: ResourceLocation): BufferedImage? + + /** + * @return true if the game is launched in the development environment rather than production + */ + fun isDevelopment(): Boolean +} diff --git a/api/src/main/kotlin/gg/essential/api/utils/Multithreading.kt b/api/src/main/kotlin/gg/essential/api/utils/Multithreading.kt new file mode 100644 index 0000000..6d74854 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/Multithreading.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicInteger + +/** + * Utility for running functions asynchronously. + */ +object Multithreading { + private val counter = AtomicInteger(0) + + @JvmStatic + val scheduledPool: ScheduledExecutorService = Executors.newScheduledThreadPool(10) { target: Runnable? -> + Thread(target, "Essential Thread " + counter.incrementAndGet()) + } + + @JvmStatic + val pool: ThreadPoolExecutor + get() = POOL + + var POOL = ThreadPoolExecutor( + 10, 30, + 0L, TimeUnit.SECONDS, + LinkedBlockingQueue() + ) { target: Runnable? -> Thread(target, "Essential ${counter.incrementAndGet()}") } + + fun schedule(r: Runnable, initialDelay: Long, delay: Long, unit: TimeUnit): ScheduledFuture<*> { + return scheduledPool.scheduleAtFixedRate(r, initialDelay, delay, unit) + } + + @JvmStatic + fun schedule(r: Runnable, delay: Long, unit: TimeUnit): ScheduledFuture<*> { + return scheduledPool.schedule(r, delay, unit) + } + + @JvmStatic + fun runAsync(runnable: Runnable) { + POOL.execute(runnable) + } + + fun submit(runnable: Runnable): Future<*> { + return POOL.submit(runnable) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/gg/essential/api/utils/SerialExecutor.kt b/api/src/main/kotlin/gg/essential/api/utils/SerialExecutor.kt new file mode 100644 index 0000000..88e7d33 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/SerialExecutor.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +import java.util.concurrent.Executor + +/** + * [Executor] which delegates execution of submitted [Runnable]s to the given [Executor] in such a way that only a + * single one will be running at any point in time. + * The order in which the [Runnable]s will run matches the order in which they were submitted. + */ +internal class SerialExecutor(val executor: Executor) : Executor { + private val queue = ArrayDeque() + private var active: Runnable? = null + + @Synchronized + override fun execute(r: Runnable) { + queue.addLast { + try { + r.run() + } finally { + submitNext() + } + } + if (active == null) { + submitNext() + } + } + + @Synchronized + private fun submitNext() { + active = queue.removeFirstOrNull()?.also { executor.execute(it) } + } +} diff --git a/api/src/main/kotlin/gg/essential/api/utils/ShutdownHookUtil.kt b/api/src/main/kotlin/gg/essential/api/utils/ShutdownHookUtil.kt new file mode 100644 index 0000000..185664f --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/ShutdownHookUtil.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +/** + * Utility for running code when the game is closed. + */ +interface ShutdownHookUtil { + /** + * Register a [Runnable] to be run when the game is closed. + * + * @param task task to be run + */ + fun register(task: Runnable) + + /** + * Unregister a previously registered [Runnable]. + * + * @param task task to be unregistered + */ + fun unregister(task: Runnable) +} \ No newline at end of file diff --git a/api/src/main/kotlin/gg/essential/api/utils/TrustedHostsUtil.kt b/api/src/main/kotlin/gg/essential/api/utils/TrustedHostsUtil.kt new file mode 100644 index 0000000..5ae8021 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/TrustedHostsUtil.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +/** + * Utility for interacting with Essential's trusted image host list. + */ +interface TrustedHostsUtil { + /** + * Get all hosts from the TrustedHosts list as [TrustedHost]s. + * + * @return hosts from the TrustedHosts list. + * @see TrustedHost + */ + fun getTrustedHosts(): Set + + /** + * Get a [TrustedHost] object from the TrustedHosts list using + * it's ID. + * + * @return host with the specified id (or null if none with the id is found) + * @see TrustedHost + */ + fun getTrustedHostByID(id: String): TrustedHost? + + /** + * Add an image host to the TrustedHosts list. + * + * @param host host to be added + */ + fun addTrustedHost(host: TrustedHost) + + /** + * Remove an image host from the TrustedHosts list. + * + * @param hostId ID of the host to be removed. + */ + fun removeTrustedHost(hostId: String) + + data class TrustedHost(val id: String, val name: String, val domains: Set) +} diff --git a/api/src/main/kotlin/gg/essential/api/utils/WebUtil.kt b/api/src/main/kotlin/gg/essential/api/utils/WebUtil.kt new file mode 100644 index 0000000..4cd5ba6 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/WebUtil.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +import org.apache.commons.io.IOUtils +import java.io.* +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.Charset + +/** + * Utility for fetching data from the internet. + */ +object WebUtil { + /** + * Log information and errors to the console. + */ + @JvmStatic + var LOG = false + + /** + * Fetch a JSON object from the internet. + * + * @param url JSON location + * @return JSON as a Sk1er [JsonHolder] + * @see JsonHolder + */ + @JvmStatic + fun fetchJSON(url: String): JsonHolder = JsonHolder(fetchString(url)) + + /** + * Fetch the content of a web page. + * + * @param url page location + * @return page content + */ + @JvmStatic + fun fetchString(url: String): String? { + val escapedUrl = url.replace(" ", "%20") + if (LOG) println("Fetching $escapedUrl") + try { + setup(escapedUrl, "Mozilla/4.76 (Essential)").use { setup -> + return IOUtils.toString(setup, Charset.defaultCharset()) + } + } catch (e: Exception) { + println("Failed to fetch from $url") + e.printStackTrace() + } + return "Failed to fetch" + } + + /** + * Download a file from the internet. + * + * @param url file location + * @param file location to save the file + * @param userAgent user-agent to use for fetching the file + */ + @JvmStatic + @Throws(IOException::class) + fun downloadToFile(url: String, file: File, userAgent: String) { + FileOutputStream(file).use { output -> + BufferedInputStream(setup(url, userAgent)).use { input -> + val dataBuffer = ByteArray(1024) + var bytesRead: Int + while (input.read(dataBuffer, 0, 1024).also { bytesRead = it } != -1) { + output.write(dataBuffer, 0, bytesRead) + } + } + } + } + + @JvmStatic + @Throws(IOException::class) + private fun setup(url: String, userAgent: String): InputStream { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.useCaches = true + connection.addRequestProperty("User-Agent", userAgent) + connection.readTimeout = 15000 + connection.connectTimeout = 15000 + connection.doOutput = true + return connection.inputStream + } +} diff --git a/api/src/main/kotlin/gg/essential/api/utils/di.kt b/api/src/main/kotlin/gg/essential/api/utils/di.kt new file mode 100644 index 0000000..752ab4d --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/di.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils + +import org.apache.logging.log4j.LogManager +import org.kodein.di.direct +import org.kodein.di.instance + +/** + * Utility to quickly retrieve an instance from our dependency injection framework. The generic type + * [T] is the class that is looked up. + * + * @see gg.essential.api.DI + */ +inline fun get(): T = essentialDI!!.direct.instance() + +/** + * This will be true if Essential's DI has been initialised. + */ +var initialised: Boolean = false + private set + +/** + * Gets an instance of Essential's DI. Try not to call this directly and instead use [get]. + */ +var essentialDI: gg.essential.api.DI? = null + internal set(value) { + if (initialised) { + LogManager.getLogger("Essential - DI").error("DI already set!") + } else { + field = value + initialised = true + } + } + get() { + if (initialised) { + return field + } + throw RuntimeException("DI not initialised!") + } diff --git a/api/src/main/kotlin/gg/essential/api/utils/mojang/MojangAPI.kt b/api/src/main/kotlin/gg/essential/api/utils/mojang/MojangAPI.kt new file mode 100644 index 0000000..0c92581 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/mojang/MojangAPI.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils.mojang + +import java.util.* +import java.util.concurrent.CompletableFuture + +/** + * Utility for interacting with and getting data from the [Mojang API](https://wiki.vg/Mojang_API). + */ +interface MojangAPI { + /** + * Get the uuid of a player via their username ([wiki.vg](https://wiki.vg/Mojang_API#Username_to_UUID)). + * + * @param name player username + * @return player's uuid + */ + fun getUUID(name: String): CompletableFuture? + + /** + * Get the username of a player via their uuid ([wiki.vg](https://wiki.vg/Mojang_API#Usernames_to_UUIDs)). + * + * @param uuid player uuid + * @return player's username + */ + fun getName(uuid: UUID): CompletableFuture? + + @Deprecated("Name history has been removed from the Mojang API") + /** + * Get the username history of a player ([wiki.vg](https://wiki.vg/Mojang_API#UUID_to_Name_History)). + * + * @param uuid player uuid + * @return list of [Name] objects populated with the player's username history + * @see Name + */ + fun getNameHistory(uuid: UUID?): List? + + /** + * Get the complete player profile of a player ([wiki.vg](https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape)). + * + * @param uuid player uuid + * @return player [Profile] + * @see Profile + */ + fun getProfile(uuid: UUID): Profile? + + /** + * Send a skin change request ([wiki.vg](https://wiki.vg/Mojang_API#Change_Skin)). + * If successful, the player must leave their current server + * and log back in to view their new skin. + * + * @param accessToken session token. this is used for authentication with mojang + * @param uuid player uuid. this must match the session token + * @param model skin will use this model (alex/steve) + * @param url image url of the new skin + * @return [SkinResponse] from mojang + * @see Model + * @see SkinResponse + */ + fun changeSkin(accessToken: String, uuid: UUID, model: Model, url: String): SkinResponse? +} diff --git a/api/src/main/kotlin/gg/essential/api/utils/mojang/data.kt b/api/src/main/kotlin/gg/essential/api/utils/mojang/data.kt new file mode 100644 index 0000000..77980f8 --- /dev/null +++ b/api/src/main/kotlin/gg/essential/api/utils/mojang/data.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.api.utils.mojang + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import java.util.* + +/** + * Name history entry. + * + * @param name username + * @param changedToAt timestamp of when the name was changed + */ +data class Name(val name: String?, val changedToAt: Long?) + +/** + * Player profile ([wiki.vg](https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape)). + * + * @param id player uuid (no dashes, as string) + * @param name player username + * @param properties profile properties + */ +data class Profile(val id: String?, val name: String?, val properties: List?) { + /** + * Skin and, if present, cape. + */ + val textures: ProfileTextures + get() { + val decoded = String(Base64.getDecoder().decode(properties?.get(0)?.value)) + return ProfileTextures.fromJson(decoded)!! + } +} + +/** + * @param profileId player uuid + * @param profileName player name + * @param textures skin and cape texture urls + */ +data class ProfileTextures( + val timestamp: Long?, + val profileId: String?, + val profileName: String?, + val textures: Textures? +) { + fun toJson(): String = Gson().toJson(this) + + companion object { + fun fromJson(json: String): ProfileTextures? = + Gson().fromJson(json, ProfileTextures::class.java) + } +} + +/** + * @param skin url to player's skin + * @param cape url to player's cape + */ +data class Textures(@SerializedName("SKIN") val skin: TextureURL?, @SerializedName("CAPE") val cape: TextureURL?) + +data class TextureURL(val url: String?) + +data class Property(val name: String?, val value: String?) + +/** + * Skin model. + */ +enum class Model( + @Deprecated("This is probably not what you are looking for.") + val model: String, + /** The internal name used by Minecraft to refer to this Model. */ + val type: String, + /** The name used by Mojang services to refer to this Model (case insensitive). */ + val variant: String, +) { + STEVE("", "default", "classic"), + ALEX("slim", "slim", "slim"); + + companion object { + @JvmStatic + fun byType(str: String) = values().find { it.type == str } + + @JvmStatic + fun byTypeOrDefault(str: String) = byType(str) ?: STEVE + + @JvmStatic + fun byVariant(str: String) = values().find { it.variant.equals(str, ignoreCase = true) } + + @JvmStatic + fun byVariantOrDefault(str: String) = byVariant(str) ?: STEVE + } +} + +data class Skin(val id: String, val state: String, val url: String, val variant: String) + +data class SkinResponse(val id: String, val name: String, val skins: List?, val capes: List) diff --git a/api/src/main/resources/fabric.mod.json b/api/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..04e8a9c --- /dev/null +++ b/api/src/main/resources/fabric.mod.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "id": "essential-api", + "version": "0", + "environment": "client", + "description": "Dummy fabric.mod.json to allow Loom to remap this jar file. DO NOT DEPEND ON THIS.", + "custom": { + "modmenu": { + "badges": ["library"], + "parent": "essential" + } + } +} diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..b21c970 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() + maven(url = "https://maven.fabricmc.net/") + maven(url = "https://maven.minecraftforge.net") + maven(url = "https://maven.architectury.dev/") + maven(url = "https://repo.essential.gg/repository/maven-public") +} + +dependencies { + implementation(gradleApi()) + implementation(localGroovy()) + + val kotlinCompilerVersion = "1.9.23" // FIXME when kotlin 1.9.24 is available + + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinCompilerVersion") + implementation("org.jetbrains.kotlin:kotlin-serialization:$kotlinCompilerVersion") + implementation("org.jetbrains.dokka:dokka-gradle-plugin:1.8.10") + implementation("org.jetbrains.kotlinx:binary-compatibility-validator:0.13.1") + implementation("io.github.goooler.shadow:shadow-gradle-plugin:8.1.7") + implementation("org.ow2.asm:asm-commons:9.3") + implementation ("com.google.guava:guava:30.1.1-jre") + + implementation("gg.essential:essential-gradle-toolkit:0.5.0") +} + +gradlePlugin { + plugins { + create("mixin") { + id = "gg.essential.mixin" + implementationClass = "gg.essential.gradle.MixinPlugin" + } + create("bundle") { + id = "gg.essential.bundle" + implementationClass = "gg.essential.gradle.BundlePlugin" + } + create("relocate") { + id = "gg.essential.relocate" + implementationClass = "gg.essential.gradle.RelocatePlugin" + } + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..5e6e207 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ diff --git a/build-logic/src/main/kotlin/essential/SyncToExternalRepoTask.kt b/build-logic/src/main/kotlin/essential/SyncToExternalRepoTask.kt new file mode 100644 index 0000000..58ad51f --- /dev/null +++ b/build-logic/src/main/kotlin/essential/SyncToExternalRepoTask.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.* +import org.gradle.process.ExecSpec +import java.io.ByteArrayOutputStream +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.pathString + +private val sourceCommitRegex = Regex("Source-Commit: (\\w+)") + +abstract class SyncToExternalRepoTask : DefaultTask() { + + @get:Input + abstract val targetRepoPath: Property + private val _targetRepoPath by targetRepoPath + + @get:Input + abstract val targetDirectories: ListProperty + + @get:Input + abstract val sourceDirectories: ListProperty + + @get:Input + abstract val replacements: ListProperty> + + private val srcRepoPath = project.rootDir.toPath() + + @TaskAction + fun sync() { + //make sure the target directories exist in the target repository + targetDirectories.get().forEach { directory -> + val directoryPath = _targetRepoPath.resolve(directory) + if (!Files.exists(directoryPath)) { + Files.createDirectories(directoryPath) + } + } + // get synced commit(s) in the target repo + val targetFilters = targetDirectories.get().toTypedArray() + val syncedCommitMessages = project.git(_targetRepoPath, "log", "--format=%b", "-E", "--grep=${sourceCommitRegex.pattern}", "--", *targetFilters) + // parse the source commit hash(es) + val syncedCommits = sourceCommitRegex.findAll(syncedCommitMessages).map { it.groupValues[1] }.toSet() + val latestSyncedCommit = syncedCommits.firstOrNull() + // create revision range of "from last synced to head" + val revisionsSinceLastSyncedCommit = if (latestSyncedCommit == null) "HEAD" else "$latestSyncedCommit..HEAD" + // get commits to sync + val sourceFilters = sourceDirectories.get().toTypedArray() + // Here we use a format string without speficying the leading `format:` which results in linebreaks following + // each line. We then split by linebreak and drop the last one as the trailing linebreak results in a single + // empty string + val commitsToSync = project.git(srcRepoPath, "log", "--format=%H %s", "--reverse", "--topo-order", revisionsSinceLastSyncedCommit, "--", *sourceFilters) + .split("\n") + .dropLast(1) + if (commitsToSync.isEmpty()) { + println("No commits to sync") + return + } + commitsToSync.forEach { commitAndSubject -> + val (commit, subject) = commitAndSubject.split(" ", limit = 2) + if (commit in syncedCommits) { + return@forEach + } + println("Applying $commit $subject") + // get diff of current commit + val diff = project.git(srcRepoPath, "show", commit, "--remerge-diff", "--", *sourceFilters) + // process diff by applying replacements + val processed = replacements.get().fold(diff) { processing, (replaced, replacement) -> + processing.replace(replaced, replacement) + } + // apply commit diff using stdin + try { + project.git(_targetRepoPath, "apply", "--allow-empty") { + standardInput = processed.byteInputStream() + } + } catch (e: Exception) { + project.git(_targetRepoPath, "apply", "--reject", "--allow-empty") { + standardInput = processed.byteInputStream() + } + } + // ignore commit if diff was empty (e.g. trivial merge commit) + if (project.git(_targetRepoPath, "status", "--porcelain").isBlank()) { + return@forEach + } + // stage changes + project.git(_targetRepoPath, "add", "--", *targetFilters) + // get source commit message + val srcCommitMsg = project.git(srcRepoPath, "show", "--format=%B", "--no-patch", commit) + val srcCommitData = configureCommitData(commit) + // commit changes + project.git(_targetRepoPath, "commit", "-m", "$srcCommitMsg\n" + + "Source-Commit: $commit") { + environment(srcCommitData) + } + } + } + + private fun configureCommitData(hash: String): Map = + mapOf( + "GIT_COMMITER_NAME" to project.git(srcRepoPath, "show", "--format=%cn", "--no-patch", hash), + "GIT_COMMITER_EMAIL" to project.git(srcRepoPath, "show", "--format=%ce", "--no-patch", hash), + "GIT_COMMITER_DATE" to project.git(srcRepoPath, "show", "--format=%ai", "--no-patch", hash), + "GIT_AUTHOR_NAME" to project.git(srcRepoPath, "show", "--format=%an", "--no-patch", hash), + "GIT_AUTHOR_EMAIL" to project.git(srcRepoPath, "show", "--format=%ae", "--no-patch", hash), + "GIT_AUTHOR_DATE" to project.git(srcRepoPath, "show", "--format=%ai", "--no-patch", hash), + ) +} + +private fun Project.git(workingDirectory: Path, command: String, vararg args: String, configure: ExecSpec.() -> Unit = {}) = + ByteArrayOutputStream().use { stream -> + project.exec { + commandLine("git") + args("-C", workingDirectory.pathString) + args(command) + args(*args) + + standardOutput = stream + + configure() + + // If we are on windows, we need to properly escape arguments passed + if (System.getProperty("os.name").contains("windows", ignoreCase = true)) { + this.args = this.args.map { arg -> + // https://learn.microsoft.com/en-us/cpp/cpp/main-function-command-line-args?view=msvc-170#parsing-c-command-line-arguments + arg.split('"').joinToString("\\\"", prefix = "\"", postfix = "\"") { part -> + part + part.takeLastWhile { it == '\\' } + } + } + } + } + stream.toString() + } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/essential/dokka.kt b/build-logic/src/main/kotlin/essential/dokka.kt new file mode 100644 index 0000000..98e4f88 --- /dev/null +++ b/build-logic/src/main/kotlin/essential/dokka.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.* +import org.jetbrains.dokka.gradle.DokkaTask + +fun Project.configureDokkaForEssentialApi() { + tasks.getByName("dokkaHtml") { + // Set module name displayed in the final output + moduleName.set("EssentialAPI") + + dokkaSourceSets { + "main" { + jdkVersion.set(8) + displayName.set("EssentialAPI") + } + } + } +} diff --git a/build-logic/src/main/kotlin/essential/embedded-loader.gradle.kts b/build-logic/src/main/kotlin/essential/embedded-loader.gradle.kts new file mode 100644 index 0000000..b763190 --- /dev/null +++ b/build-logic/src/main/kotlin/essential/embedded-loader.gradle.kts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +import gg.essential.gradle.multiversion.Platform +import java.nio.file.FileSystems +import java.nio.file.Files +import kotlin.io.path.nameWithoutExtension + +plugins { + java +} + +val platform: Platform by extensions + +val dependency = when { + platform.isFabric -> "gg.essential:loader-fabric:1.2.1" + platform.isModLauncher && platform.mcVersion >= 11700 -> "gg.essential:loader-modlauncher9:1.2.1" + platform.isModLauncher -> "gg.essential:loader-modlauncher8:1.2.1" + platform.isLegacyForge -> "gg.essential:loader-launchwrapper:1.2.1" + else -> throw UnsupportedOperationException("No known loader variant for current platform.") +} + +val loader: Configuration by configurations.creating + +dependencies { + loader(dependency) +} + +tasks.jar { + if (platform.isLegacyForge) { + dependsOn(loader) + from({ loader.map { zipTree(it) } }) + + manifest { + attributes( + "FMLModType" to "LIBRARY", + "TweakClass" to "gg.essential.loader.stage0.EssentialSetupTweaker", + "TweakOrder" to "0", + ) + } + } + + if (platform.isFabric) { + // We include the jar ourselves (and configure our fabric.mod.json accordingly), so we have control over where + // the embedded jars are located. This is important because the default location of `META-INF/jars` gets special + // treatment by loader-stage2, and we don't want that for the embedded loader. + from(loader) { + rename { "essential-loader.jar" } + } + } +} + +/** + * Takes the stage1 jar from the input stage0 jar and copies it to a different location in the output jar. + * We do this so we don't need to do any double-unpacking at runtime when we upgrade the installed stage1 version, and + * because the raw loader stored above will actually be stripped when the Essential jar is installed via stage2 (because + * the regular upgrade path would break with relaunching; we instead need to use the `stage1.update.jar` path). + */ +abstract class Stage1JarTransform : TransformAction { + @get:InputArtifact + abstract val input: Provider + + override fun transform(outputs: TransformOutputs) { + val input = input.get().asFile.toPath() + val output = outputs.file(input.nameWithoutExtension + "-stage1-only.jar").toPath() + FileSystems.newFileSystem(input, null as ClassLoader?).use { sourceFs -> + val source = sourceFs.getPath("gg/essential/loader/stage0/stage1.jar") + FileSystems.newFileSystem(output, mapOf("create" to true)).use { targetFs -> + val target = targetFs.getPath("gg/essential/loader-stage1.jar") + Files.createDirectories(target.parent) + Files.copy(source, target) + } + } + } +} + +dependencies { + val attr = Attribute.of("stage1Jar", Boolean::class.javaObjectType) + dependencies.registerTransform(Stage1JarTransform::class.java) { + from.attribute(attr, false) + to.attribute(attr, true) + } + dependencies.artifactTypes.all { + attributes.attribute(attr, false) + } + // Note: May need to pre-bundle this if we ever want to have the real thing on the classpath too; each dependency + // can only be present once per configuration. + implementation("bundle"(dependency) { + attributes { attribute(attr, true) } + }) +} diff --git a/build-logic/src/main/kotlin/essential/pinned-jar.gradle.kts b/build-logic/src/main/kotlin/essential/pinned-jar.gradle.kts new file mode 100644 index 0000000..e02bb36 --- /dev/null +++ b/build-logic/src/main/kotlin/essential/pinned-jar.gradle.kts @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +import com.google.common.hash.Hashing +import gg.essential.gradle.multiversion.Platform +import gg.essential.gradle.util.CONSTANT_TIME_FOR_ZIP_ENTRIES +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +plugins { + java +} + +val platform: Platform by extensions + +val loaderVariant = when { + platform.isFabric -> "fabric" + platform.isModLauncher && platform.mcVersion >= 11700 -> "modlauncher9" + platform.isModLauncher -> "modlauncher8" + platform.isLegacyForge -> "launchwrapper" + else -> throw UnsupportedOperationException("No known loader variant for current platform.") +} + +val loaderContainer: Configuration by configurations.creating +val loaderStage2: Configuration by configurations.creating + +dependencies { + loaderContainer("gg.essential.loader:container-$loaderVariant:included") { isTransitive = false } + loaderStage2("gg.essential.loader:stage2-$loaderVariant:included") { isTransitive = false } +} + +tasks.named("bundleJar") { + val loaderStage2Properties = layout.buildDirectory.file("essential-loader-stage2.properties") + + dependsOn(loaderContainer) + from({ zipTree(loaderContainer.singleFile) }) { + into("pinned") + } + from(loaderStage2) { + into("pinned") + } + from(loaderStage2Properties) { + into("pinned") + } + + val loaderStage2Version = provider { + loaderStage2.resolvedConfiguration.resolvedArtifacts.single().moduleVersion.id.version.also { + assert(it != "included") // we expect the actual version of the included build, not our dummy version + } + } + inputs.property("stage2Version", loaderStage2Version) + doFirst { + // Not using java.util.Properties because we want reproducible results (fairly sure the values won't need + // escaping anyway, so this should be decently safe) + loaderStage2Properties.get().asFile.writeText(""" + pinnedFile=/${loaderStage2.singleFile.name} + pinnedFileVersion=${loaderStage2Version.get()} + pinnedFileMd5=${Hashing.md5().hashBytes(loaderStage2.singleFile.readBytes())} + """.trimIndent()) + } +} + +abstract class PinnedJar : DefaultTask() { + @get:OutputFile + abstract val outputJar: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val inputJar: RegularFileProperty + + @get:Input + abstract val inputVersion: Property + + @TaskAction + fun convertToPinnedJar() { + val input = inputJar.get().asFile + val output = outputJar.get().asFile + + fun constantZipEntry(name: String) = ZipEntry(name).apply { time = CONSTANT_TIME_FOR_ZIP_ENTRIES } + + output.outputStream().use { fileOut -> + ZipOutputStream(fileOut).use { zipOut -> + val inputBytes = input.readBytes() + val inputMd5 = Hashing.md5().hashBytes(inputBytes).toString() + + ZipInputStream(inputBytes.inputStream()).use { zipIn -> + while (true) { + val entry = zipIn.nextEntry ?: break + if (!entry.name.startsWith("pinned/")) continue + val targetName = entry.name.removePrefix("pinned/") + if (targetName.isEmpty()) continue + zipOut.putNextEntry(constantZipEntry(targetName)) + zipIn.copyTo(zipOut) + zipOut.closeEntry() + } + } + + zipOut.putNextEntry(constantZipEntry("essential-$inputMd5.jar")) + zipOut.write(inputBytes) + zipOut.putNextEntry(constantZipEntry("essential-loader.properties")) + zipOut.write(""" + pinnedFile=/essential-$inputMd5.jar + pinnedFileVersion=${inputVersion.get()} + pinnedFileMd5=$inputMd5 + publisherSlug=essential + modSlug=essential + displayName=Essential + """.trimIndent().encodeToByteArray()) + } + } + } +} + +val pinnedJar by tasks.registering(PinnedJar::class) { + outputJar.set(layout.buildDirectory.file(provider { + val modVersion = project.version.toString().replace('.', '-') + val mcVersion = platform.mcVersionStr.replace('.', '-') + "libs/pinned_essential_${modVersion}_${platform.loaderStr}_${mcVersion}.jar" + })) + inputJar.set(tasks.named("relocatedJar", Jar::class).flatMap { it.archiveFile }) + inputVersion.set(provider { project.version.toString() }) +} +tasks.assemble { dependsOn(pinnedJar) } diff --git a/build-logic/src/main/kotlin/essential/preprocessor.kt b/build-logic/src/main/kotlin/essential/preprocessor.kt new file mode 100644 index 0000000..06effa4 --- /dev/null +++ b/build-logic/src/main/kotlin/essential/preprocessor.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +import com.replaymod.gradle.preprocess.RootPreprocessExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.* +import java.io.File + +fun Project.configurePreprocessTree(versions: File) { + configure { + val fabric12006 = createNode("1.20.6-fabric", 12006, "yarn") + val forge12004 = createNode("1.20.4-forge", 12004, "srg") + val fabric12004 = createNode("1.20.4-fabric", 12004, "yarn") + val forge12002 = createNode("1.20.2-forge", 12002, "srg") + val fabric12002 = createNode("1.20.2-fabric", 12002, "yarn") + val forge12001 = createNode("1.20.1-forge", 12001, "srg") + val fabric12001 = createNode("1.20.1-fabric", 12001, "yarn") + val fabric12000 = createNode("1.20-fabric", 12000, "yarn") + val forge11904 = createNode("1.19.4-forge", 11904, "srg") + val fabric11904 = createNode("1.19.4-fabric", 11904, "yarn") + val forge11903 = createNode("1.19.3-forge", 11903, "srg") + val fabric11903 = createNode("1.19.3-fabric", 11903, "yarn") + val forge11902 = createNode("1.19.2-forge", 11902, "srg") + val fabric11902 = createNode("1.19.2-fabric", 11902, "yarn") + val fabric11900 = createNode("1.19-fabric", 11900, "yarn") + val forge11802 = createNode("1.18.2-forge", 11802, "srg") + val fabric11802 = createNode("1.18.2-fabric", 11802, "yarn") + val fabric11801 = createNode("1.18.1-fabric", 11801, "yarn") + val forge11701 = createNode("1.17.1-forge", 11701, "srg") + val fabric11701 = createNode("1.17.1-fabric", 11701, "yarn") + val fabric11602 = createNode("1.16.2-fabric", 11602, "yarn") + val forge11602 = createNode("1.16.2-forge", 11602, "srg") + val forge11202 = createNode("1.12.2-forge", 11202, "srg") + val forge10809 = createNode("1.8.9-forge", 10809, "srg") + + fabric12006.link(fabric12004) + forge12004.link(fabric12004) + fabric12004.link(fabric12002, versions.resolve("1.20.4-1.20.2.txt")) + forge12002.link(fabric12002) + fabric12002.link(fabric12001, versions.resolve("1.20.2-1.20.1.txt")) + forge12001.link(fabric12001) + fabric12001.link(fabric12000) + fabric12000.link(fabric11904) + forge11904.link(fabric11904) + fabric11904.link(fabric11903) + forge11903.link(fabric11903) + fabric11903.link(fabric11902, versions.resolve("1.19.3-1.19.2.txt")) + forge11902.link(fabric11902) + fabric11902.link(fabric11900) + fabric11900.link(fabric11802) + forge11802.link(fabric11802) + fabric11802.link(fabric11801) + fabric11801.link(fabric11701) + forge11701.link(fabric11701) + fabric11701.link(fabric11602, versions.resolve("1.17.1-1.16.2.txt")) + fabric11602.link(forge11602) + forge11602.link(forge11202, versions.resolve("1.16.2-1.12.2.txt")) + forge11202.link(forge10809, versions.resolve("1.12.2-1.8.9.txt")) + } +} diff --git a/build-logic/src/main/kotlin/essential/repos.kt b/build-logic/src/main/kotlin/essential/repos.kt new file mode 100644 index 0000000..b9f4243 --- /dev/null +++ b/build-logic/src/main/kotlin/essential/repos.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +import org.gradle.api.artifacts.dsl.RepositoryHandler +import org.gradle.kotlin.dsl.* + +// Using our mirror because JitPack itself is very unreliable from time to time +fun RepositoryHandler.jitpack() = maven("https://repo.essential.gg/repository/maven-public") + +fun RepositoryHandler.minecraft() = maven("https://libraries.minecraft.net") + +fun RepositoryHandler.mixin() = maven("https://repo.spongepowered.org/repository/maven-releases/") + +fun RepositoryHandler.modMenu() = maven("https://maven.terraformersmc.com/releases/") { + content { + includeGroup("com.terraformersmc") + } +} + +// Documentation: https://docs.modrinth.com/maven +fun RepositoryHandler.modrinth() = maven("https://api.modrinth.com/maven") { + content { + includeGroup("maven.modrinth") + } +} diff --git a/build-logic/src/main/kotlin/essential/universal.kt b/build-logic/src/main/kotlin/essential/universal.kt new file mode 100644 index 0000000..21e9c7b --- /dev/null +++ b/build-logic/src/main/kotlin/essential/universal.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +import gg.essential.gradle.multiversion.StripReferencesTransform +import gg.essential.gradle.util.KotlinVersion +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.attributes.Attribute +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByType + +fun Project.universalLibs() { + val versionCatalogs = extensions.getByType() + val catalog = versionCatalogs.named("libs") + + fun getVersion(name: String) = catalog.findVersion(name).orElseThrow() + + dependencies { + val compileOnly = "compileOnly" + + val universalAttr = Attribute.of("universal", Boolean::class.javaObjectType) + + registerTransform(StripReferencesTransform::class.java) { + from.attribute(universalAttr, false) + to.attribute(universalAttr, true) + parameters { + excludes.add("net.minecraft") + } + } + + artifactTypes.all { + attributes.attribute(universalAttr, false) + } + + val kotlin = KotlinVersion.minimal + compileOnly("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin.stdlib}") + compileOnly("org.jetbrains.kotlin:kotlin-reflect:${kotlin.stdlib}") + compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlin.coroutines}") + compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:${kotlin.coroutines}") + compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-core:${kotlin.serialization}") + compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlin.serialization}") + compileOnly("org.jetbrains:annotations:23.0.0") + + // Provided by MC on 1.17+, and by `:slf4j-to-log4j` on older versions + compileOnly("org.slf4j:slf4j-api:1.7.36") + // Provided by MC (should ideally migrate away from this as MC itself is migrating to slf4j) + compileOnly("org.apache.logging.log4j:log4j-api:2.0-beta9") + // Depending on LWJGL3 instead of 2 so we can choose opengl bindings only + // Note that this will still include some methods that are not available on LWJGL2. If these are used by mistake, + // it should be obvious when the code is tested as long as our main version is still 1.12.2. If we change that, + // we should maybe considering changing this to lwjgl2 or building a small transformer that produces a jar + // containing only methods and classes that are in both versions. + compileOnly("org.lwjgl:lwjgl-opengl:3.3.1") + // Depending on 1.8.9 for all of these because that's the oldest version we support + compileOnly("com.google.code.gson:gson:2.2.4") + compileOnly("commons-codec:commons-codec:1.9") + compileOnly("org.apache.httpcomponents:httpclient:4.3.3") // TODO ideally switch to one of the libs we bundle + // These versions are configured in gradle/libs.versions.toml + compileOnly("gg.essential:vigilance-1.8.9-forge:${getVersion("vigilance")}") { + attributes { attribute(universalAttr, true) } + isTransitive = false + } + compileOnly("gg.essential:universalcraft-1.8.9-forge:${getVersion("universalcraft")}") { + attributes { attribute(universalAttr, true) } + isTransitive = false + } + compileOnly("gg.essential:elementa-1.8.9-forge:${getVersion("elementa")}") { + attributes { attribute(universalAttr, true) } + isTransitive = false + } + } +} diff --git a/build-logic/src/main/kotlin/essential/utils.gradle.kts b/build-logic/src/main/kotlin/essential/utils.gradle.kts new file mode 100644 index 0000000..aed064f --- /dev/null +++ b/build-logic/src/main/kotlin/essential/utils.gradle.kts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package essential + +// Dummy plugin which may be applied to make available all the utilitiy functions diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/BundlePlugin.kt b/build-logic/src/main/kotlin/gg/essential/gradle/BundlePlugin.kt new file mode 100644 index 0000000..3fc2f56 --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/BundlePlugin.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle + +import gg.essential.gradle.multiversion.Platform +import gg.essential.gradle.util.RelaxFabricLoaderDependencyTransform +import gg.essential.gradle.util.SlimKotlinForForgeTransform +import gg.essential.gradle.util.kotlinVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.attributes.Attribute +import org.gradle.kotlin.dsl.* +import essential.modrinth +import net.fabricmc.loom.task.RemapJarTask +import org.gradle.api.tasks.bundling.Jar + +data class Configurations( + /** + * Dependencies which will be exploded into our jar (and ideally, though not necessarily, relocated) + */ + val bundle: Configuration, + /** + * Dependencies which will be packed into our jar without exploding (i.e. Jar-in-Jar) and then be unpacked + * by our loader (or if supported by the platform loader) + */ + val jij: Configuration, +) + +open class BundlePlugin : Plugin { + override fun apply(project: Project) { + val platform = project.extensions.getByType() + + val configurations = project.createConfigurations(platform) + project.createBundleJarTask(platform, configurations) + when { + platform.isFabric -> project.configureForFabricLoader(configurations, platform) + platform.isModLauncher -> project.configureForModLauncher(configurations, platform) + } + } +} + +private fun Project.createConfigurations(platform: Platform): Configurations { + val bundle by configurations.creating { + exclude(module = "fabric-loader") // specifying module only, so the yarn-mapped version in excluded as well + exclude(group = "net.minecraftforge", module = "forge") + if (platform.mcVersion >= 11700) { + exclude(group = "org.slf4j", module = "slf4j-api") + } + } + + val jij by configurations.creating { + } + + return Configurations(bundle, jij) +} + +private fun Project.createBundleJarTask(platform: Platform, configurations: Configurations) = with(configurations) { + val jar by tasks.existing(Jar::class) + val remapJar by tasks.existing(RemapJarTask::class) { + archiveClassifier.set("mapped") + destinationDirectory.set(buildDir.resolve("devlibs")) + } + + tasks.register("bundleJar") { + archiveClassifier.set("bundle") + destinationDirectory.set(buildDir.resolve("devlibs")) + + manifest = jar.get().manifest + from(remapJar.flatMap { it.archiveFile }.map { zipTree(it) }) + + dependsOn(bundle) + from({ bundle.map { if (it.isDirectory) it else zipTree(it) } }) { + exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA") + exclude("META-INF/services/javax.annotation.processing.Processor") + + // TODO these should not be published in the first place (did I already fix that?) + exclude("gg.essential.vigilance.example.ExampleMod") + exclude("dummyThing") + + // TODO figure out where these are coming from and stop that (content is an unhelpful "examplemod") + exclude("pack.mcmeta") + + exclude("*LICENSE*") + exclude("*license*") + exclude("README.md") // FIXME why are these in UC suddenly? and why is that not an issue on release/1.2? + + // TODO I don't know how Java's module system works and if we need to somehow merge these. + // For the time being, just removing them should be fine as LaunchWrapper only supports Java 8 anyway. + exclude("**/module-info.class") + + if (platform.isLegacyForge) { + // Legacy Forge chokes on these (and they are useless for it anyway cause it only supports Java 8) + exclude("**/module-info.class") + exclude("META-INF/versions/9/**") + // Same with these coming from Mixin for ModLauncher9 support + exclude("org/spongepowered/asm/launch/MixinLaunchPlugin.class") + exclude("org/spongepowered/asm/launch/MixinTransformationService.class") + exclude("org/spongepowered/asm/launch/platform/container/ContainerHandleModLauncherEx*") + } + + var i = 0 + filesMatching("META-INF/NOTICE*") { name += ".${i++}" } + i = 0 + filesMatching("META-INF/LICENSE*") { name += ".${i++}" } + + // FIXME should ideally use JiJ but that gets tricky with loading at runtime + exclude("fabric.mod.json") + } + + from(jij) { + rename { "META-INF/jars/$it" } + } + } +} + +// On fabric-loader we use its Jar-in-Jar mechanism (indirectly via essential-loader) to load our libs. This allows +// everything to work well even if a third-party mod ships an older version of one of our libs thanks to fabric-loader +// always picking the most recent one of all JiJ jars. +private fun Project.configureForFabricLoader(configurations: Configurations, platform: Platform) = with(configurations) { + val include by project.configurations + include.exclude(group = "net.fabricmc", module = "fabric-loader") // can't upgrade this one via JiJ (unfortunately) + + val relaxedFabricLoaderDependency = Attribute.of("relaxed-fabric-loader-dependency", Boolean::class.javaObjectType) + dependencies.registerTransform(RelaxFabricLoaderDependencyTransform::class.java) { + from.attribute(relaxedFabricLoaderDependency, false) + to.attribute(relaxedFabricLoaderDependency, true) + } + dependencies.artifactTypes.all { + attributes.attribute(relaxedFabricLoaderDependency, false) + } + + fun Configuration.excludeKotlin() { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "org.jetbrains", module = "annotations") + } + + // For Kotlin, we instead bundle the official fabric-language-kotlin mod + bundle.excludeKotlin() + dependencies { + val kotlin = platform.kotlinVersion + include("net.fabricmc:fabric-language-kotlin:${kotlin.mod}+kotlin.${kotlin.stdlib}") { + attributes { + attribute(relaxedFabricLoaderDependency, true) + } + } + // FLK doesn't include this but we need it for our commands API + include("org.jetbrains:annotations:13.0") + } + + afterEvaluate { // need to delay so repos and deps are all set up + // Then we need to find all our mod jars + project.configurations + // declared in the `modApi` configuration of the corresponding api project + .detachedConfiguration(dependencies.project(":api:" + project.name, configuration = "modApi")) + .apply { excludeKotlin() } // kotlin is already taken care of + .resolvedConfiguration + .resolvedArtifacts + .map { it.moduleVersion.id } + .forEach { + // exclude them from the bundled configuration + bundle.exclude(module = it.name) // name-only so we get the remapped one as well + // and instead add them to loom's include configuration + dependencies { + include(group = it.group, name = it.name, version = it.version) + } + } + } +} + +// ModLauncher does not allow one package to be present in two different jars, and it does not give us any way to +// tell whether a package is already taken. The only way for us to not die at boot when another (e.g.) Kotlin mod is +// present, is to Jar-in-Jar (use a custom scheme, cause ModLauncher doesn't yet support that either) the most +// popular one (Java will pick the jar with the higher implementation-version. I hope. at least it seems to be fine with +// two jars declaring the same module) and then hope that everyone sticks with it. +private fun Project.configureForModLauncher(configurations: Configurations, platform: Platform) = with(configurations) { + + val slimKFF = Attribute.of("slim-kotlin-for-forge", Boolean::class.javaObjectType) + dependencies.registerTransform(SlimKotlinForForgeTransform::class.java) { + from.attribute(slimKFF, false) + to.attribute(slimKFF, true) + } + dependencies.artifactTypes.all { + attributes.attribute(slimKFF, false) + } + + bundle.exclude(group = "org.jetbrains.kotlin") + bundle.exclude(group = "org.jetbrains.kotlinx") + bundle.exclude(group = "org.jetbrains", module = "annotations") + repositories { + modrinth() + } + dependencies { + val kotlin = platform.kotlinVersion + jij("maven.modrinth:kotlin-for-forge:${kotlin.mod}") { + attributes { + // Given we are about to JiJ updated Kotlin libraries, we can strip the old ones from KFF to reduce our + // bundled jar size a bit (just need to make sure we bundle at least the same libs as KFF so we don't + // break third-party mods that depend on those). + attribute(slimKFF, true) + } + } + + jij("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin.stdlib}") + jij("org.jetbrains.kotlin:kotlin-reflect:${kotlin.stdlib}") + jij("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlin.coroutines}") + jij("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:${kotlin.coroutines}") + jij("org.jetbrains.kotlinx:kotlinx-serialization-core:${kotlin.serialization}") + jij("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlin.serialization}") + jij.exclude(group = "org.jetbrains", module = "annotations") + } +} diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/MixinPlugin.kt b/build-logic/src/main/kotlin/gg/essential/gradle/MixinPlugin.kt new file mode 100644 index 0000000..1ed6dc9 --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/MixinPlugin.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle + +import gg.essential.gradle.multiversion.Platform +import essential.mixin +import net.fabricmc.loom.api.LoomGradleExtensionAPI +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.* + +open class MixinPlugin : Plugin { + override fun apply(project: Project) { + val platform = project.extensions.getByType() + + project.configureMixin(platform) + } +} + +private fun Project.configureMixin(platform: Platform) { + configureLoomMixin() + + if (!platform.isFabric) { + addMixinDependency(platform) + } +} + +private fun Project.configureLoomMixin() { + extensions.configure { + mixin { + defaultRefmapName.set("mixins.essential.refmap.json") + } + } +} + +private fun Project.addMixinDependency(platform: Platform) { + repositories { + mixin() + } + + dependencies { + if (platform.mcVersion < 11400) { + // Our special mixin which has its Guava 21 dependency relocated, so it can run alongside Guava 17 + "implementation"(project(":mixin-compat")) + // and outside dev, with extra patches for improved backwards compat (we cannot easily use those in dev + // cause IntelliJ does not run any tasks during import) + configurations.matching { it.name == "bundle" }.configureEach { + "bundle"(project(":mixin-compat", "patched")) + } + } + + // Use more recent mixin AP so we get reproducible refmaps (and hopefully less bugs in general) + if (!System.getProperty("idea.sync.active", "false").toBoolean()) { + "annotationProcessor"("net.fabricmc:sponge-mixin:0.12.5+mixin.0.8.5") + } + } +} diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/RelocatePlugin.kt b/build-logic/src/main/kotlin/gg/essential/gradle/RelocatePlugin.kt new file mode 100644 index 0000000..f8240c6 --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/RelocatePlugin.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle + +import com.github.jengelman.gradle.plugins.shadow.relocation.SimpleRelocator +import org.gradle.api.Plugin +import org.gradle.api.Project +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import gg.essential.gradle.util.CONSTANT_TIME_FOR_ZIP_ENTRIES +import java.nio.file.Files +import kotlinx.validation.KotlinApiBuildTask +import kotlinx.validation.KotlinApiCompareTask +import org.gradle.api.Action +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.bundling.Jar +import org.gradle.kotlin.dsl.* +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.commons.ClassRemapper +import org.objectweb.asm.commons.Remapper +import java.nio.file.StandardCopyOption +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +open class RelocatePlugin : Plugin { + override fun apply(project: Project) { + val relocateTask = project.createRelocateTask() + project.createAbiValidationTasks(relocateTask) + } +} + +open class RelocateTask : ShadowJar() { + @get:Internal + internal val mappings = mutableMapOf() + + override fun relocate(pattern: String, destination: String, configure: Action?): ShadowJar { + mappings[pattern] = destination + return super.relocate(pattern, destination, configure) + } + + @TaskAction + override fun copy() { + super.copy() + + // The shadow plugin only remaps at most one class per string, so we need to manually remap extra ones in Mixin + // target references because those may contain more than one Ice4J class reference. + val remapper = + object : Remapper() { + override fun mapValue(value: Any?): Any { + if (value is String) { + return value.replace("Lorg/ice4j/", "Lgg/essential/lib/ice4j/") + } + return super.mapValue(value) + } + } + + val output = archiveFile.get().asFile.toPath() + val tmp = Files.createTempFile(output.parent, "", ".jar") + try { + Files.move(output, tmp, StandardCopyOption.REPLACE_EXISTING) + ZipOutputStream(Files.newOutputStream(output)).use { zipOut -> + // Also need to sort all zip entries because diff-updates always produce sorted output and we need our + // jars to exactly match the result for it to be accepted. + ZipFile(tmp.toFile()).use { zipFile -> + for (inputEntry in zipFile.entries().toList().sortedBy { it.name }) { + val zipIn = zipFile.getInputStream(inputEntry) + val name = inputEntry.name + + val outputEntry = ZipEntry(name) + outputEntry.time = CONSTANT_TIME_FOR_ZIP_ENTRIES + zipOut.putNextEntry(outputEntry) + + if (name.startsWith("gg/essential/mixins/transformers/feature/ice/common") && name.endsWith(".class")) { + val reader = ClassReader(zipIn.readBytes()) + val writer = ClassWriter(reader, 0) + reader.accept(ClassRemapper(writer, remapper), 0) + zipOut.write(writer.toByteArray()) + } else { + zipIn.copyTo(zipOut) + } + + zipOut.closeEntry() + } + } + } + } finally { + Files.deleteIfExists(tmp) + } + } +} + +private fun Project.createRelocateTask(): TaskProvider { + val relocatedJar by tasks.registering(RelocateTask::class) { + val jarTask = tasks.getByName("bundleJar") + from(jarTask.archiveFile) + manifest.inheritFrom(jarTask.manifest) + } + project.tasks.named("assemble") { dependsOn(relocatedJar) } + return relocatedJar +} + +private fun Project.createAbiValidationTasks(relocateTask: TaskProvider) { + fun KotlinApiBuildTask.configureAbi() { + // Should maybe move our impl into a dedicated package? (but not before `next` merge) + ignoredClasses += listOf( + "gg.essential.Essential", + "gg.essential.DI", + ) + ignoredPackages += listOf( + "gg.essential.asm", + "gg.essential.clipboard", + "gg.essential.commands", + "gg.essential.compatibility", + "gg.essential.config", + "gg.essential.cosmetics", + "gg.essential.data", + "gg.essential.dev", + "gg.essential.event", + "gg.essential.gui", + "gg.essential.handlers", + "gg.essential.image", + "gg.essential.key", + "gg.essential.main", + "gg.essential.mixins", + "gg.essential.mod", + "gg.essential.model", + "gg.essential.network", + "gg.essential.render", + "gg.essential.serialization", + "gg.essential.sps", + "gg.essential.util", + // Internally pre-relocated dependencies + "gg.essential.lib.gson", + // Internally pre-relocated dependencies (for mixin-compat) + "gg.essential.lib.guava21", + // Packages pulled in via connection-manager dependency + "com.sparkuniverse.toolbox", + "gg.essential.connectionmanager", + "gg.essential.enums", + "gg.essential.holder", + "gg.essential.media", + "gg.essential.notices", + "gg.essential.serverdiscovery", + "gg.essential.upnp", + "gg.essential.forge", + ) + + // These are our internal dependencies as declared in the relocatedJar task and as the name implies, we don't + // care about their public API. + for ((src, dst) in relocateTask.get().mappings) { + ignoredPackages += src + ignoredPackages += dst + } + } + + val devAbiBuild by tasks.registering(KotlinApiBuildTask::class) { + configureAbi() + val jarTask = tasks.getByName("bundleJar") + dependsOn(jarTask) + inputClassesDirs = zipTree(jarTask.archiveFile) + inputDependencies = zipTree(jarTask.archiveFile) + outputApiDir = buildDir.resolve("abiDev") + } + + val relocatedAbiBuild by tasks.registering(KotlinApiBuildTask::class) { + configureAbi() + val jarTask = relocateTask.get() + dependsOn(jarTask) + inputClassesDirs = zipTree(jarTask.archiveFile) + inputDependencies = zipTree(jarTask.archiveFile) + outputApiDir = buildDir.resolve("abiRelocated") + } + + val relocatedAbiCheck by tasks.registering(KotlinApiCompareTask::class) { + dependsOn(devAbiBuild) + dependsOn(relocatedAbiBuild) + projectApiDir = devAbiBuild.get().outputApiDir + apiBuildDir = relocatedAbiBuild.get().outputApiDir + } + tasks.named("check").configure { + dependsOn(relocatedAbiCheck) + } +} diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/compatmixin/CompatMixinTask.kt b/build-logic/src/main/kotlin/gg/essential/gradle/compatmixin/CompatMixinTask.kt new file mode 100644 index 0000000..23c4c9f --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/compatmixin/CompatMixinTask.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle.compatmixin + +import gg.essential.gradle.util.CONSTANT_TIME_FOR_ZIP_ENTRIES +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes +import org.objectweb.asm.commons.ClassRemapper +import org.objectweb.asm.commons.SimpleRemapper +import org.objectweb.asm.tree.AnnotationNode +import org.objectweb.asm.tree.ClassNode +import java.nio.file.Path +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +abstract class CompatMixinTask : DefaultTask() { + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val mixinClasses: ConfigurableFileCollection + + @get:InputFile + abstract val input: RegularFileProperty + + @get:OutputFile + abstract val output: RegularFileProperty + + @TaskAction + fun apply() { + val excludedClasses = mutableSetOf( + CompatMixin, + CompatShadow, + CompatAccessTransformer, + ) + + val mixins = mutableMapOf() + for (classFile in this.mixinClasses.asFileTree.files) { + if (classFile.extension != "class") { + continue + } + + val cls = ClassNode().apply { ClassReader(classFile.readBytes()).accept(this, 0) } + val annotation = cls.invisibleAnnotations?.find { it.desc == CompatMixin.desc } ?: continue + val args = annotation.args + val target = args["value"]?.toString()?.removeSurrounding("L", ";")?.replace('/', '.') + ?: args["target"]?.toString() + ?: throw IllegalArgumentException("`@CompatMixin` annotation in $classFile is invalid.") + + if (target in mixins) { + throw IllegalArgumentException("Multiple `@CompatMixin`s for \"$target\".") + } + mixins[target] = Mixin(classFile.toPath(), cls) + excludedClasses += cls.name.replace('/', '.') + } + + val mixinToTargetMapping = mixins.entries.associate { (target, mixin) -> + mixin.node.name to target.replace('.', '/') + } + val mixinRemapper = SimpleRemapper(mixinToTargetMapping) + + ZipOutputStream(output.get().asFile.outputStream()).use { zipOut -> + ZipInputStream(input.get().asFile.inputStream()).use { zipIn -> + while (true) { + val inputEntry = zipIn.nextEntry ?: break + if (classForFile(inputEntry.name) in excludedClasses) { + continue + } + + val outputEntry = ZipEntry(inputEntry.name) + outputEntry.time = CONSTANT_TIME_FOR_ZIP_ENTRIES + zipOut.putNextEntry(outputEntry) + + val mixin = mixins.remove(classForFile(inputEntry.name)) + if (mixin != null) { + val cls = ClassNode().apply { ClassReader(zipIn).accept(this, 0) } + + merge(mixin.node, cls) + + zipOut.write(ClassWriter(0).apply { + cls.accept(ClassRemapper(this, mixinRemapper)) + }.toByteArray()) + } else { + zipIn.copyTo(zipOut) + } + + zipOut.closeEntry() + } + } + } + + if (mixins.isNotEmpty()) { + throw IllegalArgumentException(mixins.map { (cls, mixin) -> + "Failed to find target \"$cls\" for \"${mixin.source}\"" + }.joinToString("\n")) + } + } + + private fun classForFile(path: String) = path + .removeSuffix(".class") + .replace('/', '.') + .replace('\\', '.') + + private fun merge(mixin: ClassNode, cls: ClassNode) { + // Mixin targets Java 6, but we don't want to be as limited in terms of language features + cls.version = Opcodes.V1_8 + + // Process shadows first, before we add other methods (with potentially the same name) + mixin.methods.removeIf { method -> + val shadow = method.invisibleAnnotations?.find { it.desc == CompatShadow.desc } + ?: return@removeIf false + + val originalName = shadow.args["original"] + if (originalName != null) { + val originalMethod = cls.methods.find { it.name == originalName && it.desc == method.desc } + ?: throw IllegalArgumentException("Could not find original method \"$originalName\" in ${cls.name}") + originalMethod.name = method.name + } + true + } + + // Then merge the remaining methods into the target class + for (method in mixin.methods) { + if (method.name == "") { + continue + } + + if (method.name == "") { + throw UnsupportedOperationException("Class initializer merging is not implemented.") + } + + cls.methods.add(method) + } + + // Apply access transformations + val accessTransformer = mixin.invisibleAnnotations?.find { it.desc == CompatAccessTransformer.desc } + if (accessTransformer != null) { + (accessTransformer.args["add"] as? List<*>)?.forEach { + cls.access = cls.access or it as Int + } + (accessTransformer.args["remove"] as? List<*>)?.forEach { + cls.access = cls.access and (it as Int).inv() + } + } + + // Merge interfaces + for (itf in mixin.interfaces) { + if (itf !in cls.interfaces) { + cls.interfaces.add(itf) + } + } + } + + private val AnnotationNode.args get() = (values ?: emptyList()).chunked(2) { (k, v) -> k to v }.toMap() + + private val String.desc get() = "L${replace('.', '/')};" + + private data class Mixin( + val source: Path, + val node: ClassNode, + ) + + companion object Annotation { + const val CompatMixin = "gg.essential.CompatMixin" + const val CompatShadow = "gg.essential.CompatShadow" + const val CompatAccessTransformer = "gg.essential.CompatAccessTransformer" + } +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/util/KotlinVersion.kt b/build-logic/src/main/kotlin/gg/essential/gradle/util/KotlinVersion.kt new file mode 100644 index 0000000..7bde3bc --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/util/KotlinVersion.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle.util + +import gg.essential.gradle.multiversion.Platform + +data class KotlinVersion( + /** Version of fabric-language-kotlin or KotlinForForge */ + val mod: String?, + val stdlib: String, + val coroutines: String, + val serialization: String, +) { + companion object { + val latest = KotlinVersion(null, "1.9.23", "1.8.0", "1.6.3") + + val fabricLanguageKotlin = latest.copy(mod = "1.10.19") + val kotlinForForge1 = latest.copy(mod = "1.17.0") + val kotlinForForge2 = latest.copy(mod = "2.2.0") + val kotlinForForge3 = latest.copy(mod = "3.6.0") + val kotlinForForge4 = latest.copy(mod = "4.3.0") + + val minimal = latest + } +} + +val Platform.kotlinVersion + get() = + when { + isModLauncher -> + when (mcVersion) { + 12004, 12002, 12001, 11904, 11903 -> KotlinVersion.kotlinForForge4 + 11902, 11802 -> KotlinVersion.kotlinForForge3 + 11701 -> KotlinVersion.kotlinForForge2 + 11602 -> KotlinVersion.kotlinForForge1 + else -> throw UnsupportedOperationException("Missing Kotlin version for $this") + } + isFabric -> KotlinVersion.fabricLanguageKotlin + else -> KotlinVersion.latest + } diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/util/RelaxFabricLoaderDependencyTransform.kt b/build-logic/src/main/kotlin/gg/essential/gradle/util/RelaxFabricLoaderDependencyTransform.kt new file mode 100644 index 0000000..0ad6b16 --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/util/RelaxFabricLoaderDependencyTransform.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle.util + +import org.gradle.api.artifacts.transform.InputArtifact +import org.gradle.api.artifacts.transform.TransformAction +import org.gradle.api.artifacts.transform.TransformOutputs +import org.gradle.api.artifacts.transform.TransformParameters +import org.gradle.api.file.FileSystemLocation +import org.gradle.api.provider.Provider +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +abstract class RelaxFabricLoaderDependencyTransform : TransformAction { + @get:InputArtifact + abstract val input: Provider + + override fun transform(outputs: TransformOutputs) { + val input = input.get().asFile + val output = outputs.file(input.nameWithoutExtension + "-relaxed-fabricloader.jar") + + output.outputStream().use { fileOut -> + ZipOutputStream(fileOut).use { out -> + ZipFile(input).use { zipFile -> + for (entry in zipFile.entries()) { + out.putNextEntry(ZipEntry(entry.name).apply { time = CONSTANT_TIME_FOR_ZIP_ENTRIES }) + if (entry.name == "fabric.mod.json") { + val json = zipFile.getInputStream(entry).readAllBytes().decodeToString() + val relaxedJson = json.replace(Regex(""""fabricloader" *: *"[^"]+""""), """"fabricloader": "*"""") + out.write(relaxedJson.encodeToByteArray()) + } else { + zipFile.getInputStream(entry).copyTo(out) + } + out.closeEntry() + } + } + } + } + } +} diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/util/SlimKotlinForForgeTransform.kt b/build-logic/src/main/kotlin/gg/essential/gradle/util/SlimKotlinForForgeTransform.kt new file mode 100644 index 0000000..527fc81 --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/util/SlimKotlinForForgeTransform.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle.util + +import org.gradle.api.artifacts.transform.InputArtifact +import org.gradle.api.artifacts.transform.TransformAction +import org.gradle.api.artifacts.transform.TransformOutputs +import org.gradle.api.artifacts.transform.TransformParameters +import org.gradle.api.file.FileSystemLocation +import org.gradle.api.provider.Provider +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +abstract class SlimKotlinForForgeTransform : TransformAction { + @get:InputArtifact + abstract val input: Provider + + override fun transform(outputs: TransformOutputs) { + val input = input.get().asFile + val output = outputs.file(input.nameWithoutExtension + "-slim.jar") + + fun constantZipEntry(name: String) = ZipEntry(name).apply { time = CONSTANT_TIME_FOR_ZIP_ENTRIES } + + output.outputStream().use { fileOut -> + ZipOutputStream(fileOut).use { zipOut -> + ZipInputStream(input.inputStream()).use { zipIn -> + while (true) { + val entry = zipIn.nextEntry ?: break + if (!(entry.name.startsWith("kotlin/") || entry.name.startsWith("kotlinx/") + // Also need to delete the coroutines version file, so that lib gets overwritten as well + || entry.name == "META-INF/kotlinx_coroutines_core.version")) { + zipOut.putNextEntry(entry) + zipIn.copyTo(zipOut) + zipOut.closeEntry() + } + } + } + + // Need to have at least one class in the `kotlin` package so loader correctly identifies this jar as KFF. + zipOut.putNextEntry(constantZipEntry("kotlin/")) + zipOut.closeEntry() + zipOut.putNextEntry(constantZipEntry("kotlin/Unit.class")) + zipOut.closeEntry() + } + } + } +} diff --git a/build-logic/src/main/kotlin/gg/essential/gradle/util/StripKotlinMetadataTransform.kt b/build-logic/src/main/kotlin/gg/essential/gradle/util/StripKotlinMetadataTransform.kt new file mode 100644 index 0000000..d9bf3dd --- /dev/null +++ b/build-logic/src/main/kotlin/gg/essential/gradle/util/StripKotlinMetadataTransform.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gradle.util + +import org.gradle.api.Project +import org.gradle.api.artifacts.transform.InputArtifact +import org.gradle.api.artifacts.transform.TransformAction +import org.gradle.api.artifacts.transform.TransformOutputs +import org.gradle.api.artifacts.transform.TransformParameters +import org.gradle.api.attributes.Attribute +import org.gradle.api.file.FileSystemLocation +import org.gradle.api.provider.Provider +import org.objectweb.asm.AnnotationVisitor +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes +import java.io.Closeable +import java.io.File +import java.util.jar.JarInputStream +import java.util.jar.JarOutputStream +import java.util.zip.ZipEntry + +abstract class StripKotlinMetadataTransform : TransformAction { + interface Parameters : TransformParameters + + @get:InputArtifact + abstract val input: Provider + + override fun transform(outputs: TransformOutputs) { + val input = input.get().asFile + val output = outputs.file(input.nameWithoutExtension + "-without-kotlin-metadata.jar") + (input to output).useInOut { jarIn, jarOut -> + while (true) { + val entry = jarIn.nextJarEntry ?: break + val originalBytes = jarIn.readBytes() + + val modifiedBytes = if (entry.name.endsWith(".class")) { + val reader = ClassReader(originalBytes) + val writer = ClassWriter(reader, 0) + reader.accept(object : ClassVisitor(Opcodes.ASM9, writer) { + override fun visitAnnotation(descriptor: String, visible: Boolean): AnnotationVisitor? { + if (descriptor == "Lkotlin/Metadata;") { + return null + } + return super.visitAnnotation(descriptor, visible) + } + }, 0) + writer.toByteArray() + } else { + originalBytes + } + + jarOut.putNextEntry(ZipEntry(entry.name)) + jarOut.write(modifiedBytes) + jarOut.closeEntry() + } + } + } + + private inline fun Pair.useInOut(block: (jarIn: JarInputStream, jarOut: JarOutputStream) -> Unit) = + first.inputStream().nestedUse(::JarInputStream) { jarIn -> + second.outputStream().nestedUse(::JarOutputStream) { jarOut -> + block(jarIn, jarOut) + } + } + + private inline fun T.nestedUse(nest: (T) -> U, block: (U) -> Unit) = + use { nest(it).use(block) } + + companion object { + fun Project.registerStripKotlinMetadataAttribute(name: String, configure: Parameters.() -> Unit = {}): Attribute { + val attribute = Attribute.of(name, Boolean::class.javaObjectType) + + dependencies.registerTransform(StripKotlinMetadataTransform::class.java) { + from.attribute(attribute, false) + to.attribute(attribute, true) + parameters(configure) + } + + dependencies.artifactTypes.all { + attributes.attribute(attribute, false) + } + + return attribute + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dc13bc0 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.* +import gg.essential.gradle.util.* +import gg.essential.gradle.util.StripKotlinMetadataTransform.Companion.registerStripKotlinMetadataAttribute + +plugins { + id("kotlin") + id("org.jetbrains.kotlin.plugin.serialization") + id("io.github.goooler.shadow") + id("gg.essential.defaults") + id("gg.essential.defaults.repo") + id("gg.essential.multi-version") + id("gg.essential.mixin") + id("gg.essential.bundle") + id("gg.essential.relocate") + id("essential.embedded-loader") + id("essential.pinned-jar") +} + +val mcVersion: Int by project.extra +val mcVersionStr: String by project.extra +val mcPlatform: String by project.extra + +repositories { + mavenLocal() + modMenu() +} +base.archivesName.set("Essential " + project.name) + +val stripKotlinMetadata = registerStripKotlinMetadataAttribute("strip-kotlin-metadata") + +dependencies { + implementation(bundle(project(":feature-flags"))!!) + implementation(bundle(project(":libs"))!!) + implementation(bundle(project(":infra"))!!) + implementation(bundle(project(":gui:elementa"))!!) + implementation(bundle(project(":gui:essential"))!!) + implementation(bundle(project(":gui:vigilance"))!!) + implementation(project(":api:" + project.name, configuration = "namedElements")) + bundle(project(":api:" + project.name)) + + implementation(bundle("com.github.KevinPriv:keventbus:c52e0a2") { + attributes { attribute(stripKotlinMetadata, true) } + }) + implementation(bundle(project(":kdiscordipc"))!!) + + implementation(bundle(project(":cosmetics", configuration = "minecraftRuntimeElements"))!!) + + implementation(bundle(project(":lwjgl3"))!!) + runtimeOnly(bundle(project(":lwjgl3:impl"))!!) + + // In order to get proper IDE support, we want to use a non-relocated MixinExtras version in dev. + // This gets transformed by `relocatedJar` to use our bundled relocated version for production. + implementation(annotationProcessor("io.github.llamalad7:mixinextras-common:${libs.versions.mixinextras.get()}")!!) + listOf(configurations.implementation, configurations.annotationProcessor).forEach { + it.configure { + exclude(group = "gg.essential.lib", module = "mixinextras") + } + } + + implementation(bundle("org.jitsi:ice4j:3.0-52-ga9ba80e") { + exclude(module = "kotlin-osgi-bundle") + exclude(module = "guava") // we use the one which comes with Minecraft (assuming that it is not too old) + exclude(module = "java-sdp-nist-bridge") // sdp is unnecessarily high-level for our use case + exclude(module = "jna") // comes with jitsi-utils but is not needed + }) + // upgrade because the old one pulled in by ice4j got compiled by ancient kotlin and fails to remap + implementation(bundle("org.jitsi:jitsi-metaconfig:1.0-9-g5e1b624")!!) + + // Some of our dependencies rely on slf4j but that's not included in MC prior to 1.17, so we'll manually bundle a + // log4j adapter for those versions + if (platform.mcVersion < 11700) { + implementation(bundle(project(":slf4j-to-log4j"))!!) + } + implementation(bundle(project(":quic-connector"))!!) + + implementation(bundle(project(":clipboard"))!!) + implementation(bundle(project(":utils"))!!) + implementation(bundle(project(":plasmo"))!!) + + testImplementation(kotlin("test")) + + if (platform.isFabric && mcVersion >= 11600) { + val modMenuDependency = "com.terraformersmc:modmenu:${when { + platform.mcVersion >= 11800 -> "3.0.0" + platform.mcVersion <= 11700 -> "1.16.22" + else -> "2.0.14" + }}" + val modMenuInDev = mcVersion < 11802 // included fabric-screen-api-v1 is incompatible with 1.18.2 + if (modMenuInDev) { + modImplementation(modMenuDependency) + } else { + modCompileOnly(modMenuDependency) + } + } + + // Want to test with Optifine in your development environment? + // Set this to true, reload the Gradle project and add the Optifine jar into your mods folder. + // Bonus: Run with -Doptifabric.extract=true to get extracted and remapped OF classes/patches in `run/.optifine`. + val optifabricInDev = false + if (optifabricInDev) { + modImplementation("com.github.Chocohead:OptiFabric:e570a19") { + exclude(group = "net.fabricmc") + exclude(group = "net.fabricmc.fabric-api") + } + modImplementation("net.fabricmc.fabric-api:fabric-api:0.40.0+1.17") + } + + if (platform.isFabric && platform.mcVersion >= 12006) { + val fapiVersion = when (platform.mcVersion) { + 12006 -> "0.97.8+1.20.6" + else -> error("No fabric API version configured!") + } + include(modImplementation(fabricApi.module("fabric-api-base", fapiVersion))!!) + include(modImplementation(fabricApi.module("fabric-networking-api-v1", fapiVersion))!!) + } + + + constraints { + val kotlin = KotlinVersion.minimal + val reason: DependencyConstraint.() -> Unit = { + because("this is the most recent version supported by all platforms") + } + for (name in listOf("stdlib", "stdlib-common", "stdlib-jdk7", "stdlib-jdk8")) { + implementation("org.jetbrains.kotlin:kotlin-$name:${kotlin.stdlib}!!", reason) + } + implementation("org.jetbrains.kotlin:kotlin-reflect:${kotlin.stdlib}!!", reason) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlin.coroutines}!!", reason) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:${kotlin.coroutines}!!", reason) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:${kotlin.serialization}!!", reason) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlin.serialization}!!", reason) + } +} + +if (platform.isFabric) { + // Compile against the oldest fabric-loader version we support, so we don't accidentially use APIs available + // only in newer versions + configurations.compileClasspath { + resolutionStrategy.force("net.fabricmc:fabric-loader:0.11.0") + } +} + +tasks.jar { + manifest { + attributes( + "ModSide" to "CLIENT", + "FMLCorePluginContainsFMLMod" to "Yes, yes it does", + "Main-Class" to "gg.essential.main.Main", + ) + } + if (!platform.isFabric) { + manifest { + if (mcVersion >= 11400) { + attributes("MixinConfigs" to "mixins.essential.json,mixins.essential.init.json,mixins.essential.modcompat.json") + attributes("Requires-Essential-Stage2-Version" to "1.6.0") + } + } + } +} + +// For legacy Forge, we need to use a custom tweaker to get mixin bootstrapped +if (platform.isLegacyForge) { + loom.runs.named("client") { + programArgs("--tweakClass", "gg.essential.dev.DevelopmentTweaker") + } +} + +// Essential is a client-side only mod +loom.noServerRunConfigs() + +// Enable dev-only feature flag +loom.runs.named("client") { + property("essential.feature.dev_only", "true") +} + +// We need to use the compatibility mode on old versions because we used to use the old Kotlin defaults for those, and +// we need to match the API to be able to override its methods. +tasks.compileKotlin.setJvmDefault(if (platform.mcVersion >= 11400) "all" else "all-compatibility") + +tasks.relocatedJar { + //Discord + relocate("dev.cbyrne.kdiscordipc", "gg.essential.lib.kdiscordipc") + + // MojangAPI & keventbus + relocate("me.kbrewster", "gg.essential.lib.kbrewster") + relocate("okhttp3", "gg.essential.lib.okhttp3") + relocate("okio", "gg.essential.lib.okio") + + // ice4j + relocate("org.ice4j", "gg.essential.lib.ice4j") + relocate("org.jitsi", "gg.essential.lib.jitsi") + relocate("com.typesafe.config", "gg.essential.lib.typesafeconfig") + relocate("org.json.simple", "gg.essential.lib.jsonsimple") + relocate("org.bitlet.weupnp", "gg.essential.lib.weupnp") + + // connection-manager + relocate("org.java_websocket", "gg.essential.lib.websocket") + + // cosmetics + relocate("dev.folomeev.kotgl", "gg.essential.lib.kotgl") + + if (mcVersion < 11700) { + // Slf4j + relocate("org.slf4j", "gg.essential.lib.slf4j") + } + + // MixinExtras + relocate("com.llamalad7.mixinextras", "gg.essential.lib.mixinextras") +} + +tasks.processResources { + inputs.property("project_version", project.version) + filesMatching("assets/essential/version.txt") { + expand(mapOf("version" to project.version)) + } +} + +tasks.test { + useJUnitPlatform() +} + diff --git a/changelog/release-1.0.0.1.md b/changelog/release-1.0.0.1.md new file mode 100644 index 0000000..cc30fe8 --- /dev/null +++ b/changelog/release-1.0.0.1.md @@ -0,0 +1,16 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Wardrobe +- Optifine capes now show over Mojang capes equipped through the Wardrobe +- Fixed sorting options not applying when changing categories + +## Misc +- Fixed crash when entering Minecraft controls with Essential disabled +- Fixed crash from corrupted screenshot metadata +- Fixed changelogs modal not showing up after major or minor version update +- Fixed crash when tabbing out of Screenshot Editor +- Fixed incorrect version showing in the Essential Menu +- Fixed back button getting cut off at some aspect ratios +- Fixed failure to connect to the Essential Network if Minecraft took longer than 60 seconds to launch + diff --git a/changelog/release-1.0.0.10.1.md b/changelog/release-1.0.0.10.1.md new file mode 100644 index 0000000..f98df5c --- /dev/null +++ b/changelog/release-1.0.0.10.1.md @@ -0,0 +1,4 @@ +Title: Bug Patch +Summary: Minor bug fixes + +- Fix 1.0.0.10 using incorrect jar for Fabric 1.19.1 diff --git a/changelog/release-1.0.0.10.md b/changelog/release-1.0.0.10.md new file mode 100644 index 0000000..bb1a052 --- /dev/null +++ b/changelog/release-1.0.0.10.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## New platform +- Added support for Forge 1.19.2 + +## Compatibility + - Avoid log spamming when encountering compatibility conflicts with player display diff --git a/changelog/release-1.0.0.2.md b/changelog/release-1.0.0.2.md new file mode 100644 index 0000000..972e077 --- /dev/null +++ b/changelog/release-1.0.0.2.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Compatibility +- Fixed crash when opening the controls screen with Essential disabled +- Fixed Essential not showing up in the mod list diff --git a/changelog/release-1.0.0.3.md b/changelog/release-1.0.0.3.md new file mode 100644 index 0000000..74e5354 --- /dev/null +++ b/changelog/release-1.0.0.3.md @@ -0,0 +1,17 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Misc +- Fixed changelog or experimental features modals showing more than once +- Added right click menu to groups in the Social Menu +- Fixed typing in the Screenshot Browser causing screenshots to become white +- Improved media embeds in the messenger +- Removed erroneous "Unhandled error" logs +- Removed keybinds when the user did not accept the Essential Terms of Service +- Fixed Screenshot Browser breaking with large amounts of screenshots +- Minor UI fixes + +## Account Manager +- Improved saving and loading +- Removed Mojang accounts + diff --git a/changelog/release-1.0.0.4.md b/changelog/release-1.0.0.4.md new file mode 100644 index 0000000..6c7419c --- /dev/null +++ b/changelog/release-1.0.0.4.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Compatibility +- Fixed crashes starting the game with certain mods for other Minecraft versions in the mods folder diff --git a/changelog/release-1.0.0.5.md b/changelog/release-1.0.0.5.md new file mode 100644 index 0000000..4128f57 --- /dev/null +++ b/changelog/release-1.0.0.5.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Cosmetics +- Fixed cosmetics sometimes appearing white or with incorrect texture diff --git a/changelog/release-1.0.0.6.md b/changelog/release-1.0.0.6.md new file mode 100644 index 0000000..67ccb9e --- /dev/null +++ b/changelog/release-1.0.0.6.md @@ -0,0 +1,7 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Misc + - Import previous TOS accept/deny state on new installs + - Add link confirmation modal to Social Menu + - Minor UI fixes diff --git a/changelog/release-1.0.0.8.md b/changelog/release-1.0.0.8.md new file mode 100644 index 0000000..fa461e3 --- /dev/null +++ b/changelog/release-1.0.0.8.md @@ -0,0 +1,15 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Cosmetics +- Introduce a new cape option to display any 3rd party capes another mod may add to your character +- Fixed log spam when loading cosmetics for the first time on some platforms +- Improved cosmetic loading sometimes causing game stuttering + +## Invite Friends +- A toast is now sent to all friends when you share a world on the friends privacy setting +- Improved the calculation of the best route for a lower latency connection in some situations + +## Misc +- Minor UI fixes +- Create a setting to bypass the link confirmation modal for trusted hosts diff --git a/changelog/release-1.0.0.9.md b/changelog/release-1.0.0.9.md new file mode 100644 index 0000000..3b1188e --- /dev/null +++ b/changelog/release-1.0.0.9.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Misc + - Use Mojang profiles API to resolve names ahead of API changes + - Allow tab navigation of modals + - Fix SPS issues when the language was not English + - Minor UI fixes diff --git a/changelog/release-1.0.0.md b/changelog/release-1.0.0.md new file mode 100644 index 0000000..b599733 --- /dev/null +++ b/changelog/release-1.0.0.md @@ -0,0 +1,40 @@ +Title: Full Release +Summary: Redesign of all UIs and improved Screenshot experience + +## Screenshots + +- (1.8.9 & 1.12.2 only) Screenshots are now taken asynchronously without lag +- Browse all screenshots you've taken in Minecraft and sort them by world or server +- Draw on and crop your screenshots in the Screenshot Editor before saving them +- Upload screenshots to share a link +- Taking a screenshot now provides a preview and quick hover actions including Upload, Copy, Edit, and Favorite + +## Invite Friends + +- Reduced latency whenever a direct connection is not possible +- Improved connection quality +- Fixed some issues causing players to time out + +## Wardrobe + +- Renamed Customizer to Wardrobe +- Redesigned Wardrobe UI +- Show Capes on the player in Wardrobe and Menu +- Add Cape selector for official Mojang Capes +- Added gifting - gift cosmetics to your friends! + +## New Platforms + +- Add support for 1.16.5 on Forge and Fabric +- Add support for 1.18.2 Forge + +## Other + +- Redesigned all UI screens +- Added "Open Minecraft Folder" button +- Added the Essential Menu with Changelogs and Legal Notices +- Display Changelogs after version updates +- Improvements to Purchase Notification +- Changed onboarding to be more user-friendly +- Added button to refresh session when "Invalid Session" screen is encountered +- Bugfixes and improvements diff --git a/changelog/release-1.1.0.1.md b/changelog/release-1.1.0.1.md new file mode 100644 index 0000000..cf2cccd --- /dev/null +++ b/changelog/release-1.1.0.1.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes + - Fixed crash on world join on some Forge installs + - Fixed Discord cape being unlockable in Wardrobe without joining Discord diff --git a/changelog/release-1.1.0.10.md b/changelog/release-1.1.0.10.md new file mode 100644 index 0000000..668be9f --- /dev/null +++ b/changelog/release-1.1.0.10.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Wardrobe + - Added Spooky collection + - Added "Featured" sort option diff --git a/changelog/release-1.1.0.2.md b/changelog/release-1.1.0.2.md new file mode 100644 index 0000000..4fed46c --- /dev/null +++ b/changelog/release-1.1.0.2.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug fixes + - Fixed changelogs modal not showing update description + - Fixed a crash involving certain resource packs diff --git a/changelog/release-1.1.0.3.md b/changelog/release-1.1.0.3.md new file mode 100644 index 0000000..c75e7cd --- /dev/null +++ b/changelog/release-1.1.0.3.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes + - Fixed some modals having inconsistent height + - Fixed resource packs not being shared on 1.16+ + - Fixed crash in legacy forge dev env + - Fixed World Settings screen being accessible in multiplayer diff --git a/changelog/release-1.1.0.4.md b/changelog/release-1.1.0.4.md new file mode 100644 index 0000000..9082b33 --- /dev/null +++ b/changelog/release-1.1.0.4.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Settings +- Disable sharing current server IP on Discord by default diff --git a/changelog/release-1.1.0.5.md b/changelog/release-1.1.0.5.md new file mode 100644 index 0000000..611f4f0 --- /dev/null +++ b/changelog/release-1.1.0.5.md @@ -0,0 +1,10 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Cosmetics + - Discord cape now shows in the owned tab + - Fixed cosmetic animations animating relative to an incorrect position + +## Compatibility + - Fixed 1.17 forge crash due to mixin error + - Improved compatibility with player display diff --git a/changelog/release-1.1.0.6.md b/changelog/release-1.1.0.6.md new file mode 100644 index 0000000..46102b7 --- /dev/null +++ b/changelog/release-1.1.0.6.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Commands + - Rename `/invite` to `/einvite` and `/session` to `/esession` to avoid conflicting with server commands diff --git a/changelog/release-1.1.0.7.md b/changelog/release-1.1.0.7.md new file mode 100644 index 0000000..8f2bdee --- /dev/null +++ b/changelog/release-1.1.0.7.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes + - Fixed cosmetic previews sometimes applying outside the Wardrobe diff --git a/changelog/release-1.1.0.9.md b/changelog/release-1.1.0.9.md new file mode 100644 index 0000000..1f638c0 --- /dev/null +++ b/changelog/release-1.1.0.9.md @@ -0,0 +1,12 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Compatibility + - Improved compatibility with the player display and Figura mod + +## Bug fixes + - Removed ModCore warning setting on platforms ModCore wasn't released on + - Fixed TOS modal immediately showing on first boot. It now shows when players interact with a feature that requires the TOS to be accepted. + - Fixed sorting not working in some situations in the Wardrobe + - Misc minor UI fixes and changes + - Fixed the displayed sale percentage in the Wardrobe diff --git a/changelog/release-1.1.0.md b/changelog/release-1.1.0.md new file mode 100644 index 0000000..72d3ac9 --- /dev/null +++ b/changelog/release-1.1.0.md @@ -0,0 +1,27 @@ +Title: Player Hosting Update +Summary: Discord integration and improvements to Player Hosting + +## Player Hosting + +- Added "Host world" button to the main menu to quickly invite friends to a world +- New world settings screen to manage gamerules, player permissions, and other settings +- Added PvP gamerule to toggle player combat +- Added support for **Plasmo Voice** and **Simple Voice Chat** mods +- Last used world settings are now remembered for each world +- Added commands for the host to invite, kick, and op other players +- Improvements in connection quality using regional proxies where a direct connection is not possible +- Ability to share your Resource Pack when hosting a world + +## Discord Integration + +- Essential is now shown in the Activity Status on Discord if enabled +- Invite friends to your world over Discord using the + button next to the message input field +- Added a free cape unlocked by joining the Essential Discord + +## Other + +- Added setting to disable Essential entirely +- Added setting to disable cosmetics and control how they interact with armor +- Updated "Friend online" notification +- Set privacy settings for each server individually +- Fixed player UUIDs instead of names being displayed in some GUIs diff --git a/changelog/release-1.1.1.1.md b/changelog/release-1.1.1.1.md new file mode 100644 index 0000000..b5073e3 --- /dev/null +++ b/changelog/release-1.1.1.1.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed an issue where the Social Menu opened randomly diff --git a/changelog/release-1.1.1.2.md b/changelog/release-1.1.1.2.md new file mode 100644 index 0000000..70bded0 --- /dev/null +++ b/changelog/release-1.1.1.2.md @@ -0,0 +1,11 @@ +Title: Bug Patch +Summary: Minor Bug Fixes + +## Compatibility + - Fixed compatibility with Dynamic Player Progression and Difficulty mod + +## Bug Fixes + - Fixed screenshot editor size on retina displays + - Fixed accessing of Wardrobe via hotkey when cosmetics are disabled + - Fixed `/inviteworld` and other commands opening modals not working + - Misc minor UI fixes and tweaks diff --git a/changelog/release-1.1.1.3.md b/changelog/release-1.1.1.3.md new file mode 100644 index 0000000..76e4b8a --- /dev/null +++ b/changelog/release-1.1.1.3.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor Bug Fixes + +## Compatibility + - Improved compatibility of hosting and joining worlds on old Mac OS versions diff --git a/changelog/release-1.1.1.md b/changelog/release-1.1.1.md new file mode 100644 index 0000000..864f5ec --- /dev/null +++ b/changelog/release-1.1.1.md @@ -0,0 +1,29 @@ +Title: The Yummy Update +Summary: Sidebar main menu, message replies, and general UI improvements. + +## General +- Added setting to collapse the Essential main menu into a sidebar, or turn it off entirely +- In-game chat can now be scrolled while peeking +- Added a setting to remove quick action bar from Essential menu + +## Social Menu +- Overhauled parts of the social menu’s chat interface +- Right click to copy and paste in text input fields +- Added ability to reply to messages +- Added option to mark messages as read +- Friend requests tab now displays amount of unread requests + +## Design +- Fixed some design inconsistencies +- Updated design of on/off toggles +- Updated the folder icon +- Updated the friend request notification +- Updated the cosmetic purchase notification +- Added fancy icons to context menu actions + +## Fixes +- Gray links in “open link” modals are now clickable +- Fixed player names occasionally not resolving +- Message notifications are now properly cut off after three lines +- Modals now close when the screen changes +- Modals can now be closed with the Enter and Esc keys diff --git a/changelog/release-1.2.0.1.md b/changelog/release-1.2.0.1.md new file mode 100644 index 0000000..3fc2b1a --- /dev/null +++ b/changelog/release-1.2.0.1.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor Bug Fixes + +## Bug Fixes +- Fixed emotes canceling while holding an item on MC 1.8.9 Forge diff --git a/changelog/release-1.2.0.10.md b/changelog/release-1.2.0.10.md new file mode 100644 index 0000000..06a15a7 --- /dev/null +++ b/changelog/release-1.2.0.10.md @@ -0,0 +1,25 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## Social menu + - Ignore embed handling for links wrapped in <>. For example, + - Fixed 'Failed to load image' error showing on links that successfully loaded but did not contain an image embed + - Fixed extra toast when an announcement is edited + +## Player Hosting + - Removed 'friends' privacy setting. All sessions are now invite only + - Fixed host resource pack being zipped even when share resource pack is disabled + +## Wardrobe + - Improved loading speed of visible items inside the Wardrobe + - Adjusted minimium width of player preview to allow more content to be shown + - Fixed some keys toggling cosmetics in Wardrobe when toggle cosmetics hotkey was unbound on 1.8.9 and 1.12.2 + - Fixed Wardrobe being empty on first openening in some cases + +## Bug Fixes + - Verify user has accepted terms of service before attempting to upload screenshot via upload button on toast + - Misc minor UI fixes + +## Compatibility improvements + - Fixed conflict with NotEnoughAnimations on 1.16 + - Fixed parrots not animating on emotes on 1.16+ Forge with OptiFine diff --git a/changelog/release-1.2.0.11.md b/changelog/release-1.2.0.11.md new file mode 100644 index 0000000..7048b91 --- /dev/null +++ b/changelog/release-1.2.0.11.md @@ -0,0 +1,22 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## New Versions + - Added support for 1.19.4 Fabric + +## Bug Fixes + - Fixed some issues relating to IPv6 with Player Hosting + - Fixed cosmetic unavailable on offline mode servers toast showing when staring a Player Hosting session + - Fixed World Share Settings GUI allowing the changing of difficulty when it is locked + - Fixed 3rd party capes showing in emote previews + - Fixed synchronization of friend request privacy across installs + - Fixed skins flashing for 1 frame in player previews + - Misc UI fixes + +## Compatibility + - Fixed game freezing with Forge-Client-Reset-Packet + - Fixed menu player sometimes using wrong model (Steve when it should be Alex) with OptiFine + - Show warning about cosmetics being unavailable when connecting to offline mode servers + +## Wardrobe + - Temporarily removed "Gift" button because it did not quite work as one would expect, you can still use the web store to gift cosmetics to your friends diff --git a/changelog/release-1.2.0.12.md b/changelog/release-1.2.0.12.md new file mode 100644 index 0000000..fd3e8d3 --- /dev/null +++ b/changelog/release-1.2.0.12.md @@ -0,0 +1,12 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +**New Versions** + - Added support for 1.19.4 Forge + +**User Interface Tweaks** + - Moved Essential settings button + +**Bug Fixes** + - Fixed crash with malformed player signature + - Misc UI fixes and tweaks diff --git a/changelog/release-1.2.0.13.md b/changelog/release-1.2.0.13.md new file mode 100644 index 0000000..4240eae --- /dev/null +++ b/changelog/release-1.2.0.13.md @@ -0,0 +1,5 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## Bug Fixes +- Fixed Player Hosting sessions starting on adventure mode when opening from the main menu diff --git a/changelog/release-1.2.0.14.md b/changelog/release-1.2.0.14.md new file mode 100644 index 0000000..4aed2a6 --- /dev/null +++ b/changelog/release-1.2.0.14.md @@ -0,0 +1,11 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +Player Hosting + - Added warning when MacOS firewall state blocks Player Hosting connections + +Bug Fixes + - Fixed Optifine breaking some screenshot functionality on Fabric 1.19.4 + - Fixed Optifine breaking typing in modal inputs on Fabric 1.19.4 + - Fixed crash when using Optifine on Forge 1.19.4 + - Misc minor UI/Other fixes diff --git a/changelog/release-1.2.0.3.md b/changelog/release-1.2.0.3.md new file mode 100644 index 0000000..3153881 --- /dev/null +++ b/changelog/release-1.2.0.3.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor Bug Fixes + +## Bug Fixes +- Fixed mod conflict with optifabric +- Fixed unpurchased emotes staying on the emote wheel after leaving the wardrobe in some cases diff --git a/changelog/release-1.2.0.4.md b/changelog/release-1.2.0.4.md new file mode 100644 index 0000000..8151361 --- /dev/null +++ b/changelog/release-1.2.0.4.md @@ -0,0 +1,6 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## Bug Fixes +- Improved compatibility with Optifine +- Minor bug fixes in Emote Wheel diff --git a/changelog/release-1.2.0.5.md b/changelog/release-1.2.0.5.md new file mode 100644 index 0000000..93f6261 --- /dev/null +++ b/changelog/release-1.2.0.5.md @@ -0,0 +1,5 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## Bug Fixes +- Minor bug fixes relating to emotes diff --git a/changelog/release-1.2.0.6.md b/changelog/release-1.2.0.6.md new file mode 100644 index 0000000..707d1bb --- /dev/null +++ b/changelog/release-1.2.0.6.md @@ -0,0 +1,12 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## Bug Fixes +- Fixed Wardrobe closing when searching for items that don't exist +- Fixed collections showing outside their availability window +- Fixed fallback player renderer not looping emote animations +- Fixed typing in popups on 1.16.5 Fabric with Optifine + +## Compatibility +- Fixed compatibility between emotes and Orange Marshall's 1.7 animations +- Fixed compatibility with KotlinForForge 3.8+ diff --git a/changelog/release-1.2.0.7.md b/changelog/release-1.2.0.7.md new file mode 100644 index 0000000..1f0b32b --- /dev/null +++ b/changelog/release-1.2.0.7.md @@ -0,0 +1,20 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## New Versions + - Added support for 1.19.3 Fabric and Forge + +## Bug Fixes + - Fixed some emote bugs + - Prevent emote wheel from being opened when not authenticated on the Essential Network + - Allow escape to close modals while they are fading in + - Fixed new friend request indicator in the Social Menu persisting when accepting friend requests through a toast + - Fixed crashes in the Screenshot Browser relating to corrupted data files + - Fixed non armor items in the head armor slot not hiding when using the 'Always hide armor' setting + - Misc UI fixes and tweaks + +## Compatibility + - Resolved incompatibility with Orange Marshall's 1.7 animations mod when using the 'Hide conflicting armor' setting + - Resolved incompatibility with morechathistory mod + - Resolved incompatibility with new Kotlin4Forge versions + - Fixed typing in modals not working on 1.16.5 Fabric with Optifine diff --git a/changelog/release-1.2.0.8.md b/changelog/release-1.2.0.8.md new file mode 100644 index 0000000..9dfc1a6 --- /dev/null +++ b/changelog/release-1.2.0.8.md @@ -0,0 +1,10 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## Bug Fixes +- Fixed various connection issues with Player Hosting +- Fixed cape misbehaving when emotes are disabled in the settings +- Fixed log spam when emote keybind is pressed with no emote bound + +## Compatibility + - Fixed compatibility with emotes with Quark mod diff --git a/changelog/release-1.2.0.9.md b/changelog/release-1.2.0.9.md new file mode 100644 index 0000000..2b06ad3 --- /dev/null +++ b/changelog/release-1.2.0.9.md @@ -0,0 +1,12 @@ +Title: Bug Fix Patch +Summary: Minor Bug Fixes + +## Compatibility + - Fix emote incompatibility with NotEnoughAnimations on 1.19.x + +## Bug Fixes + - Fixed 'Loading Cosmetics' modal closing way too early while some cosmetics may still be loading + - Fixed emotes breaking elytra position + - Fixed taking a screenshot while in a text input clearing search results on MacOS + - Fixed Forge bug on 1.12.2 causing difficulty to reset when changing dimensions + - Misc UI fixes diff --git a/changelog/release-1.2.1.1.md b/changelog/release-1.2.1.1.md new file mode 100644 index 0000000..6328301 --- /dev/null +++ b/changelog/release-1.2.1.1.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed crash when rendering certain cosmetics on Minecraft 1.8.9 diff --git a/changelog/release-1.2.1.2.md b/changelog/release-1.2.1.2.md new file mode 100644 index 0000000..21e8637 --- /dev/null +++ b/changelog/release-1.2.1.2.md @@ -0,0 +1,16 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## New Versions +- Added support for 1.20 Fabric +- Unlock the Sniffer and Blossom Capes when playing with friends on MC 1.20.x using Essential World Hosting + +## Improvements +- Added setting to hide your cosmetics for yourself and other players (toggled by existing key binding) +- Messages that are still sending are now marked by a different color +- Minor UI improvements in Social Menu + +## Bug Fixes +- Fixed cosmetics in Wardrobe disappearing when emote is dragged onto them +- Fixed messages being sent out of order when connection is unstable + diff --git a/changelog/release-1.2.1.3.md b/changelog/release-1.2.1.3.md new file mode 100644 index 0000000..f2ec2fb --- /dev/null +++ b/changelog/release-1.2.1.3.md @@ -0,0 +1,10 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Show a notification when the Blossom and Sniffer capes are unlocked +- Fixed Essential menu alignment in Pause Menu in Minecraft 1.19.3 + +## Misc +- Reworded cosmetic and armor conflict settings, removed `Always hide armor` option + diff --git a/changelog/release-1.2.1.4.md b/changelog/release-1.2.1.4.md new file mode 100644 index 0000000..73eafb0 --- /dev/null +++ b/changelog/release-1.2.1.4.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## New Versions +- Added support for 1.20.1 Fabric + diff --git a/changelog/release-1.2.1.5.md b/changelog/release-1.2.1.5.md new file mode 100644 index 0000000..fac718d --- /dev/null +++ b/changelog/release-1.2.1.5.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed failure to connect via Essential World Hosting on 1.20.1 diff --git a/changelog/release-1.2.1.6.md b/changelog/release-1.2.1.6.md new file mode 100644 index 0000000..a9ae1ad --- /dev/null +++ b/changelog/release-1.2.1.6.md @@ -0,0 +1,13 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## New Versions +- Added support for 1.20.1 Forge + +## Improvements +- Added a new loading animation for image embeds in the Social Menu + +## Bug Fixes +- Stopped showing changelog modal on fresh install +- Fixed Essential Menu showing when game is paused with F3 + Esc + diff --git a/changelog/release-1.2.1.7.md b/changelog/release-1.2.1.7.md new file mode 100644 index 0000000..5891370 --- /dev/null +++ b/changelog/release-1.2.1.7.md @@ -0,0 +1,41 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Improvements +- Cheat option has new behaviour on Essential World Hosting: + - When enabled: Host gets OP and they can choose who to OP + - When disabled: No one gets OP +- Added a scrollbar to the account switcher +- Added confirmation modal when opening untrusted links in Social Menu +- Added telemetry about operating system information + +## Misc +- Removed the keybind for adding the player that you are looking at as a friend + +## Bug Fixes +- Fixed cape rendering incorrectly in pause menu while using emotes on Minecraft 1.19.3 and above +- Fixed confirm via Enter key on text modals such as the Add Friend modal +- Fixed game rule text not wrapping correctly on the World Host Settings screen +- Removed broken third party image embeds +- Removed localization of dates in the screenshot browser +- Fixed first person emote preview sometimes appearing when the "Play emotes in third person view" setting is enabled +- Fixed first person emote preview disappearing too early +- Fixed screenshots overlapping in the Screenshot Browser list view on certain resolutions +- Fixed `Copy Image` not functioning correctly with some applications on MacOS +- Fixed `Copy Image` not functioning reliably on Linux +- Fixed "Show friends what server you play on" dialog showing multiple times if screen is resized +- Fixed avatar images not loading +- Fixed spacing in "Loading cosmetics" modal +- Removed toast blocking access to Wardrobe via keybind when cosmetics are disabled +- Fixed long world names and descriptions in Select World modal +- Fixed a crash that occurred when failing to get hardware information for telemetry +- Fixed Discord usernames not being handled correctly when the user has migrated to the new username system +- Fixed markdown renderer crashing on some texts +- Fixed cosmetics not rendering in the Wardrobe when cosmetics are disabled in settings +- Fixed color of hovered links +- Fixed occasional crash when switching away from Owned Only in the Wardrobe + +## Compatibility +- Fixed position of Essential button in settings menu with NoChatReports installed +- The Screenshot Browser now works correctly with the Shared Resources mod + diff --git a/changelog/release-1.2.1.8.md b/changelog/release-1.2.1.8.md new file mode 100644 index 0000000..19b5b5b --- /dev/null +++ b/changelog/release-1.2.1.8.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed toggle button colors not updating in modals when clicked diff --git a/changelog/release-1.2.1.md b/changelog/release-1.2.1.md new file mode 100644 index 0000000..cdd34e6 --- /dev/null +++ b/changelog/release-1.2.1.md @@ -0,0 +1,19 @@ +Title: Minor Improvements +Summary: Various fixes and improvements. + +## Improvements +- Social Menu: Added reply button to messages +- Added Resource Pack Sharing support for outdated resource packs +- Screenshot Manager: Added option to view and delete screenshots uploaded to media.essential.gg +- Made "Show my Armor" setting also hide Elytra when applicable +- Unbind the Save Hotbar Activator keybind for new users to avoid conflict with Zoom hotkey +- Updated friend selection modal when inviting friends to a world +- Stopped sending a client settings packet on all cosmetic updates to avoid falsely triggering some Anti Cheats +- Various UI improvements and bug fixes + +## Compatibility +- Fixed incompatibility with Show Me Your Skin mod +- Fixed incompatibility with Not Enough Gamerules mod +- Fixed random crash with Serene Seasons mod +- Added support for FreeBSD + diff --git a/changelog/release-1.2.2.1.md b/changelog/release-1.2.2.1.md new file mode 100644 index 0000000..0acea99 --- /dev/null +++ b/changelog/release-1.2.2.1.md @@ -0,0 +1,11 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Improvements +- Updated bundled Kotlin to stdlib 1.9.10, coroutines 1.7.3 and serialization 1.6.0 on Fabric and 1.8.9/1.12.2 Forge + +## Bug Fixes +- Fixed a bug where text inside messages wouldn't get unselected properly +- Fixed a bug in the game where player data would load incorrectly when sharing your world with your friends +- Fixed an issue with drawing onto screenshots on macOS +- Fixed modals not showing on title screen with FancyMenu diff --git a/changelog/release-1.2.2.2.md b/changelog/release-1.2.2.2.md new file mode 100644 index 0000000..83ebda9 --- /dev/null +++ b/changelog/release-1.2.2.2.md @@ -0,0 +1,10 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## New Versions +- Added support for 1.20.2 Fabric + +## Bug Fixes +- Fixed a bug in the game (MCL-3732) resulting in a crash when receiving a resource pack on 1.8.9 +- Fixed a crash when inviting players to a server under certain circumstances + diff --git a/changelog/release-1.2.2.3.md b/changelog/release-1.2.2.3.md new file mode 100644 index 0000000..9bb6027 --- /dev/null +++ b/changelog/release-1.2.2.3.md @@ -0,0 +1,24 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Improvements +- Updated bundled MixinExtras to 0.2.0 + +## UI Changes +- Improved friend indicators on server entries and hosted worlds in the Multiplayer menu + +## Bug Fixes +- Fixed a bug where the "Cheats" option wouldn't have the correct value when sharing a Singleplayer world for the first time +- Fixed Add Friend modal showing "Username doesn't exist" error when typing longer usernames +- Fixed the modal fade-in transition not showing on some systems +- Fixed an issue where the Essential logo wasn't appearing on Fabric when using Mod Menu +- Fixed search in Wardrobe sometimes showing incorrect items +- Fixed an issue where muting or un-muting a player in the social menu would show an error +- Fixed a bug where the Essential screenshot sound would still play when Essential is disabled in settings +- Fixed a bug where disabling Essential wouldn't remove Essential capes +- Fixed an issue where disabling Essential through the toggle in settings would cause body parts with cosmetics on them to disappear + +## Compatibility +- Fixed Plasmo Voice 2.x not working with World Hosting +- Fixed Essential World Hosting, chat and other issues when using Vivecraft +- Fixed an issue where Essential's online indicator wouldn't show on certain servers diff --git a/changelog/release-1.2.2.4.md b/changelog/release-1.2.2.4.md new file mode 100644 index 0000000..9d614da --- /dev/null +++ b/changelog/release-1.2.2.4.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed a bug where scrolling in certain menus would cause the game to crash +- Fixed a bug causing excessive memory allocation on the multiplayer screen, potentially leading to crashes diff --git a/changelog/release-1.2.2.md b/changelog/release-1.2.2.md new file mode 100644 index 0000000..c2943ef --- /dev/null +++ b/changelog/release-1.2.2.md @@ -0,0 +1,22 @@ +Title: Update Update +Summary: Automatic updates are now opt-in! + +## Automatic Updates +- Automatic updates are now opt-in for everyone +- Added manual update option + +## Improvements +- Added ability to directly share screenshots with friends +- Screenshot quick actions are now fully configurable +- Various minor UI improvements + +## Compatibility +- Fixed Essential not showing on main menu when FancyMenu is enabled +- Fixed crashes with ViaFabric and Draggable Lists in the multiplayer menu +- Fixed proxied server pings failing with ViaFabric +- Fixed the Essential diamond being black in the player list with Iris installed +- Fixed the player's hand still being visible when zooming in with Iris installed + +## Bug Fixes +- Default trusted hosts will load properly when Essential is disabled +- Fixed avatar images not loading on older Minecraft versions diff --git a/changelog/release-1.2.3.1.md b/changelog/release-1.2.3.1.md new file mode 100644 index 0000000..425416d --- /dev/null +++ b/changelog/release-1.2.3.1.md @@ -0,0 +1,9 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed using high resolution resource packs causing the main and pause menus to freeze temporarily +- Fixed an issue where marking a group of messages as unread would attempt to mark your own messages as unread +- Fixed the option to use the Minecraft button textures on 1.20.2 +- Fixed an issue where enabling cheats when creating a world, but disabling them when sharing to your friends would still allow them to execute commands +- Fixed emotes still playing on the pause screen when they are disabled through the Essential Settings menu diff --git a/changelog/release-1.2.3.md b/changelog/release-1.2.3.md new file mode 100644 index 0000000..b3e04f2 --- /dev/null +++ b/changelog/release-1.2.3.md @@ -0,0 +1,33 @@ +Title: The Chit-Chat Update +Summary: Social menu improvements and Essential button texture setting! + +## Social Menu +- Redesigned the message input field and reply banner +- Added ability to attach screenshots to messages +- Added ability to edit messages +- Added last message sent timestamp to chat list +- Added muted indicator on friends and groups to chat list +- Added ability to save shared pictures +- Updated image embed design +- Updated background color of sent messages +- Updated color of links +- Updated world/server invite and join buttons +- Minor bug fixes + +## Pictures +- Added the option to run a quick-action after taking a screenshot +- Added a better placeholder for pictures which couldn't be loaded +- Updated default bottom-left screenshot quick action to "Copy Picture" + +## Appearance +- Added the option to use the vanilla button texture for Essential menu buttons +- Added support for resource packs to override the Essential menu button texture + +## Modals +- Added a "Create New World" button to the host world modal +- Added information to empty friend invite, make group, and share screenshot modals + +## Settings +- Updated the layout of all settings +- Updated the name and description of all settings + diff --git a/changelog/release-1.3.0.1.md b/changelog/release-1.3.0.1.md new file mode 100644 index 0000000..cc1f8d3 --- /dev/null +++ b/changelog/release-1.3.0.1.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed emote wheels not saving correctly between sessions diff --git a/changelog/release-1.3.0.2.md b/changelog/release-1.3.0.2.md new file mode 100644 index 0000000..9ed4574 --- /dev/null +++ b/changelog/release-1.3.0.2.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## New Versions +- Added support for 1.20.4 Fabric + +## Compatibility +- Fixed ReplayMod failing to record with Essential on 1.20.2 diff --git a/changelog/release-1.3.0.3.md b/changelog/release-1.3.0.3.md new file mode 100644 index 0000000..39796f1 --- /dev/null +++ b/changelog/release-1.3.0.3.md @@ -0,0 +1,21 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Improvements +- Updated bundled MixinExtras to 0.3.5 +- Auto-detect skin type (wide or slim) when adding a new skin +- Coins modal now forces creator codes to uppercase + +## Bug Fixes +- Fixed incorrect/inconsistent text truncation in various UI elements +- Fixed "Edit Message" button not appearing when hovering over anything except the first image in a message with multiple images +- Fixed commands which require permission levels higher than 2 (e.g. /tick) not working with cheats enabled on World Hosting sessions +- Fixed the Hypixel server in featured server list taking incorrect server data such as MOTDs from a different Hypixel subdomain in favorite server list +- Fixed Wardrobe crashing on emotes page when emotes are disabled in settings +- Fixed ability to upload and add skins with old format (64x32) +- Fixed game rule values being visually cut off in World Host Settings +- Fixed game rule values being limited to five digits in World Host Settings +- Fixed chunks sometimes being invisible when using emotes +- Fixed Essential capes not being disabled when cosmetics are disabled +- Fixed changing accounts using third-party account managers +- Fixed worlds shared via World Hosting remaining in the Multiplayer menu after being uninvited or closed diff --git a/changelog/release-1.3.0.4.md b/changelog/release-1.3.0.4.md new file mode 100644 index 0000000..4211d65 --- /dev/null +++ b/changelog/release-1.3.0.4.md @@ -0,0 +1,6 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## UI +- Adjusted the order and appearance of wardrobe item tags +- Moved the search bar to the left of the "Add Skin" button in the Wardrobe diff --git a/changelog/release-1.3.0.5.md b/changelog/release-1.3.0.5.md new file mode 100644 index 0000000..60f97b4 --- /dev/null +++ b/changelog/release-1.3.0.5.md @@ -0,0 +1,5 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Wardrobe +- Adjusted discount percentage rounding on item tags diff --git a/changelog/release-1.3.0.6.md b/changelog/release-1.3.0.6.md new file mode 100644 index 0000000..e7acfb7 --- /dev/null +++ b/changelog/release-1.3.0.6.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Gifting +- Improved error messaging when recipient is not an Essential user + +## Bug Fixes +- Fixed incorrect minimum coin package size being used on the purchase coins modal tooltip diff --git a/changelog/release-1.3.0.md b/changelog/release-1.3.0.md new file mode 100644 index 0000000..3622cd6 --- /dev/null +++ b/changelog/release-1.3.0.md @@ -0,0 +1,46 @@ +Title: Wardrobe Update +Summary: New cosmetics, color options, skin library, gifting, and more! + +## Cosmetics + +- Added 100 new cosmetics +- Added ability to select cosmetics on the character preview +- Added ability to gift cosmetics to friends +- Improved customization with color options and position adjustments + +## Skins + +- Added skin library for easier skin management +- Added ability to add skins via link, file, or username +- Added ability to favourite skins +- Added skin sharing via social menu or link +- Improved skin locking to outfits +- Improved syncing skins with Mojang + +## Outfits + +- Added outfits library for easier outfit management +- Added ability to favourite outfits +- Added ability to rename outfits +- Increased outfit limit to 128 + +## Emotes + +- Added in-game emote wheel switching +- Added ability to gift emotes to friends + +## Wardrobe + +- Added ability to create outfits from preview window +- Added ability to switch between outfits and emotes in preview window +- Added cosmetic and emote rarity tiers +- Added Essential Coin packs +- Added ability to support creators with Creator Codes +- Improved unlocking and purchasing flow +- Improved wardrobe sidebar design +- Improved wardrobe scaling behaviour +- Improved featured page + +## Compatibility + +- Fixed Essential not showing on main menu with FancyMenu 2.14.10 Fabric \ No newline at end of file diff --git a/changelog/release-1.3.1.1.md b/changelog/release-1.3.1.1.md new file mode 100644 index 0000000..253a473 --- /dev/null +++ b/changelog/release-1.3.1.1.md @@ -0,0 +1,15 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Wardrobe +- Items will always be sorted by price, even if they are owned +- The purchase confirmation modal will now only be shown if you have enough coins for the purchase + +## Bug Fixes +- Fixed group names being center-aligned in sharing modals +- Fixed connection failure when invite notification is clicked multiple times +- Fixed the horizon flashing black after leaving the Wardrobe +- Fixed a memory leak that occurred after closing Discord with Essential's Discord Rich Presence feature enabled on macOS or Linux + +## Compatibility +- Fixed Online Indicators not rendering with Feather Client diff --git a/changelog/release-1.3.1.2.md b/changelog/release-1.3.1.2.md new file mode 100644 index 0000000..1cb60ce --- /dev/null +++ b/changelog/release-1.3.1.2.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Wardrobe +- Improved sidebar colors and icons + +## Compatibility +- Fixed voice chat mods sometimes not working after rejoining a hosted world diff --git a/changelog/release-1.3.1.3.md b/changelog/release-1.3.1.3.md new file mode 100644 index 0000000..e5eb6be --- /dev/null +++ b/changelog/release-1.3.1.3.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed cosmetics not being affected by in-world lighting on 1.8.9 and 1.12.2 +- Fixed screenshot and fullscreen hotkey while in modal on 1.8.9 and 1.12.2 +- Fixed occasionally inconsistent player animation speed in GUIs +- Fixed incorrect alignment of outfit name in the Wardrobe title bar diff --git a/changelog/release-1.3.1.md b/changelog/release-1.3.1.md new file mode 100644 index 0000000..803970a --- /dev/null +++ b/changelog/release-1.3.1.md @@ -0,0 +1,26 @@ +Title: Bug Patch +Summary: Quality of life improvements and bug fixes + +## Improvements +- Added a list of unowned cosmetics in your outfit beside the character preview in the Wardrobe +- Added a warning to the wardrobe when your emote wheel keybind is conflicting with another keybind +- Added an indicator to skins in the Wardrobe when they are being used on an outfit +- Added a context menu option for duplicating an outfit in the Wardrobe +- Added a purchase confirmation modal when clicking the purchase button in the player preview window +- Added visible timer to bundles +- Scrolling in the in-game Emote Wheel will now cycle through the different wheels +- Added a modal shown when Essential has been auto installed by a 3rd party mod + +## Bug Fixes +- Fixed players being kicked from World Hosting when host temporarily loses connection to Essential infrastructure +- Fixed performance issues when leaving the Wardrobe open for an extended period of time +- Fixed the Essential online indicator not showing on sneaking players on 1.8.9 +- Fixed cosmetic animations resetting when changing colors +- Fixed unowned items equipped modal displaying when leaving the wardrobe quickly after purchase +- "NEW" indicator on the main menu is no longer hidden during a sale + +## Compatibility +- Fixed Essential not showing on main menu when using Custom Main Menu + +## Miscellaneous +- Moved the Essential onboarding file to a new location diff --git a/changelog/release-1.3.2.md b/changelog/release-1.3.2.md new file mode 100644 index 0000000..b0cd9d3 --- /dev/null +++ b/changelog/release-1.3.2.md @@ -0,0 +1,23 @@ +Title: Notification Refresh +Summary: Design updates for all notification toasts + +## New Versions +- Added support for 1.20.2 and 1.20.4 Forge +- Added support for 1.20.6 Fabric + +## Improvements +- Improved the appearance of all our toasts +- Added a toast on the main menu which links to the wiki on first launch +- Improved Emote Wheel behavior when the game gets disconnected from the Essential Network +- Improved Skin Upload Error modal text to be more descriptive +- Improved Coin Purchase modal to show bonus coins +- Improved Gifting modal to allow selecting multiple friends to gift to at once +- Removed "Essential GUI Scale" from settings + +## Bug Fixes +- Fixed images not displaying in the "Uploaded" tab of the Screenshot Browser +- Fixed certain random animations not playing in the Wardrobe on 1.19.3 and above +- Fixed some particles rendering completely black or not at all in the Wardrobe on 1.17 and above +- Fixed multiple issues causing the Social Menu to crash +- Fixed the unread indicator not disappearing after receiving a message in the Social Menu +- Fixed Emote Wheel having a dark background on 1.20.2 and above diff --git a/clipboard/build.gradle.kts b/clipboard/build.gradle.kts new file mode 100644 index 0000000..c625621 --- /dev/null +++ b/clipboard/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.* +import gg.essential.gradle.util.KotlinVersion +import gg.essential.gradle.util.prebundle + +plugins { + kotlin("jvm") + `java-library` +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(8)) + +repositories { + mavenCentral() + jitpack() +} + +val jnapple by configurations.creating + +configurations.compileOnly.configure { + extendsFrom(jnapple) +} + +dependencies { + implementation(kotlin("stdlib-jdk8", KotlinVersion.minimal.stdlib)) + + // We could use JNA 3.x from 1.8.9, but `Native.load` is `Native.loadLibrary` in that version. + // `Native.loadLibrary` is deprecated in 5.x, and is only provided for compatibility with older versions. + // It's safer to include the version provided by JNApple to avoid any unknown behavior. + jnapple("com.github.caoimhebyrne:JNApple:0211173") + + // This is included in the main Essential JAR + compileOnly(project(":utils")) + + api(prebundle(jnapple, jijName = "gg/essential/gui/screenshot/image/clipboard.jar") { + exclude("META-INF/INDEX.LIST") // list of packages, invalid for fat jar + exclude("META-INF/*.SF", "META-INF/*.DSA") // signatures, broken for fat jar + }) +} diff --git a/clipboard/src/main/kotlin/gg/essential/clipboard/AWTClipboard.kt b/clipboard/src/main/kotlin/gg/essential/clipboard/AWTClipboard.kt new file mode 100644 index 0000000..5e37f21 --- /dev/null +++ b/clipboard/src/main/kotlin/gg/essential/clipboard/AWTClipboard.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.clipboard + +import java.awt.Toolkit +import java.awt.datatransfer.ClipboardOwner +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException +import java.awt.image.BufferedImage +import java.io.File +import java.io.IOException +import java.util.concurrent.Semaphore +import javax.imageio.ImageIO + +class AWTClipboard: Clipboard, ClipboardOwner { + val lostOwnership = Semaphore(0) + + override fun copyPNG(file: File): Boolean { + return try { + val image = this.ensureRGBImage(ImageIO.read(file)) + Toolkit.getDefaultToolkit().systemClipboard.setContents(TransferableImage(image), this) + + true + } catch (e: IOException) { + e.printStackTrace() + false + } + } + + override fun lostOwnership(p0: java.awt.datatransfer.Clipboard?, p1: Transferable?) { + // On Linux, we want to keep serving paste requests until another application takes ownership of the clipboard. + lostOwnership.release() + } + + private fun ensureRGBImage(bufferedImage: BufferedImage): BufferedImage { + if (bufferedImage.type == BufferedImage.TYPE_INT_RGB) { + return bufferedImage + } + + return BufferedImage(bufferedImage.width, bufferedImage.height, BufferedImage.TYPE_INT_RGB).apply { + val graphics = createGraphics() + graphics.drawImage(bufferedImage, 0, 0, null) + graphics.dispose() + } + } + + /** + * Utility class for setting the system clipboard to an image + */ + private class TransferableImage(private val image: BufferedImage) : Transferable { + override fun getTransferDataFlavors(): Array { + return arrayOf(DataFlavor.imageFlavor) + } + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean { + return DataFlavor.imageFlavor.equals(flavor) + } + + @Throws(UnsupportedFlavorException::class) + override fun getTransferData(flavor: DataFlavor): Any { + if (DataFlavor.imageFlavor.equals(flavor)) { + return image + } + throw UnsupportedFlavorException(flavor) + } + } +} \ No newline at end of file diff --git a/clipboard/src/main/kotlin/gg/essential/clipboard/Clipboard.kt b/clipboard/src/main/kotlin/gg/essential/clipboard/Clipboard.kt new file mode 100644 index 0000000..82d8a2c --- /dev/null +++ b/clipboard/src/main/kotlin/gg/essential/clipboard/Clipboard.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.clipboard + +import gg.essential.util.OperatingSystem +import gg.essential.util.os +import java.io.File + +interface Clipboard { + companion object { + @JvmStatic + fun current(): Clipboard { + return when (os) { + OperatingSystem.MACOS -> MacOSClipboard() + else -> AWTClipboard() + } + } + } + + fun copyPNG(file: File): Boolean +} \ No newline at end of file diff --git a/clipboard/src/main/kotlin/gg/essential/clipboard/MacOSClipboard.kt b/clipboard/src/main/kotlin/gg/essential/clipboard/MacOSClipboard.kt new file mode 100644 index 0000000..b7565e2 --- /dev/null +++ b/clipboard/src/main/kotlin/gg/essential/clipboard/MacOSClipboard.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.clipboard + +import dev.caoimhe.jnapple.appkit.NSData +import dev.caoimhe.jnapple.appkit.NSPasteboard +import java.io.File + +/** + * Uses macOS' NSPasteboard to copy raw data of files to the clipboard. + * Related linear issues: EM-1869, EM-1607 + */ +class MacOSClipboard : Clipboard { + /** + * Reads the bytes from a [file], and copies it to the clipboard as a PNG. + */ + override fun copyPNG(file: File): Boolean { + return try { + val data = file.readBytes() + val pasteboard = NSPasteboard.generalPasteboard() + + // Apple recommends clearing the existing clipboard's contents before "providing" your own data. + pasteboard.clearContents() + pasteboard.setData(NSData.initWithBytes(data), NSPasteboard.TypePNG) + + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} \ No newline at end of file diff --git a/cosmetics/build.gradle.kts b/cosmetics/build.gradle.kts new file mode 100644 index 0000000..1bcae97 --- /dev/null +++ b/cosmetics/build.gradle.kts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import gg.essential.gradle.util.* +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("java-library") + id("gg.essential.defaults") +} + +kotlin { + jvm("minecraft") + + sourceSets["commonMain"].dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib:1.5.30") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.3.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2") + implementation(project(":feature-flags")) + api(project(":utils")) + } + + sourceSets["commonTest"].dependencies { + implementation(kotlin("test")) + } + + sourceSets["minecraftMain"].dependencies { + compileOnly("gg.essential:universalcraft-1.8.9-forge:228") { isTransitive = false } + } +} + +kotlin.jvmToolchain(8) +tasks.withType(KotlinCompile::class) { setJvmDefault("all") } diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/CosmeticsState.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/CosmeticsState.kt new file mode 100644 index 0000000..dcee8e0 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/CosmeticsState.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics + +import gg.essential.cosmetics.boxmask.ModelClipperImpl +import gg.essential.cosmetics.skinmask.SkinMaskConfig +import gg.essential.cosmetics.skinmask.SkinPartSetting +import gg.essential.mod.cosmetics.CosmeticSlot +import gg.essential.mod.cosmetics.SkinLayer +import gg.essential.mod.cosmetics.settings.CosmeticProperty +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import gg.essential.mod.cosmetics.settings.side +import gg.essential.model.BedrockModel +import gg.essential.model.Bone +import gg.essential.model.Box3 +import gg.essential.model.EnumPart +import gg.essential.model.Side +import gg.essential.model.Vector3 +import gg.essential.network.cosmetics.Cosmetic +import kotlin.jvm.JvmField + +/** + * Immutable container for all cosmetics state belonging to one entity (usually a player). + * + * Also contains various state derived for this specific combination of cosmetics (e.g. which skin layers are covered, + * parts of cosmetics hidden by other cosmetics, etc.). + * + * All this information is immutable and computed purely based on the state passed via the constructor (which should + * also be immutable). + * When any of the underlying state needs to be changed (e.g. because we received an update to their equipped + * cosmetics), a new instance is to be created. This guarantees that none of the computed state can ever be out of sync + * with the state passed via the constructor, or put another way, any change to the cosmetics by necessity also always + * updates everything that depends on it. + */ +class CosmeticsState( + /** + * The type of model in use for this player. + * The value is the Minecraft-internal name for the model (i.e. "default" or "slim"), though ideally it should just + * be treated as an opaque string. + */ + val skinType: String, + + /** + * Cosmetics which this player has equipped. + */ + val cosmetics: Map, + + /** + * Model instances. Should contain one entry for each equipped cosmetic. + */ + val bedrockModels: Map, + + /** + * All body parts which currently have armor equipped. Cosmetics which conflict with any of these will not be + * rendered. + */ + val armor: Set, +) { + + /** + * All outer skin layers which are logically covered by a cosmetic and should therefore be skipped by the vanilla + * player renderer. + */ + val coveredLayers: Set = cosmetics.values + .flatMap { cosmetic -> + fun MutableSet.putAll(map: Map?) = map?.forEach { (key, visible) -> + if (visible) remove(key) else add(key) + } + + val result = mutableSetOf() + result.putAll(cosmetic.cosmetic.type.skinLayers) + result.putAll(cosmetic.cosmetic.skinLayers) + result + }.toSet() + + /** + * For each cosmetic, contains a set of body parts on which it should not be rendered because another equipped + * cosmetic has explicitly disabled it to avoid conflict (e.g. hats tend to disable the head part of full body + * suits). + */ + private val partsHiddenDueToProperty: Map> = cosmetics.values + .flatMap { it.cosmetic.properties.filterIsInstance() } + .groupByPropertyTargetId { setting -> + val data = setting.data + buildSet { + if (data.head) { add(EnumPart.HEAD) } + if (data.body) { add(EnumPart.BODY) } + if (data.arms) { add(EnumPart.LEFT_ARM); add(EnumPart.RIGHT_ARM) } + if (data.legs) { add(EnumPart.LEFT_LEG); add(EnumPart.RIGHT_LEG) } + } + } + + /** + * For each cosmetic, contains a set of body parts on which it should not be rendered because they player has armor + * equipped in a slot which would conflict with those parts (we generally want the armor to be clearly visible as to + * not give any advantage in PvP scenarios). + */ + private val partsHiddenDueToArmor: Map> = cosmetics.values + .flatMap { it.cosmetic.properties.filterIsInstance() } + .groupByPropertyTargetId { property -> + val data = property.data + buildSet { + if (data.head) { add(EnumPart.HEAD) } + if (data.body) { add(EnumPart.BODY) } + if (data.arms) { add(EnumPart.LEFT_ARM); add(EnumPart.RIGHT_ARM) } + if (data.legs) { add(EnumPart.LEFT_LEG); add(EnumPart.RIGHT_LEG) } + }.filter { it in armor } + + } + + /** + * For each cosmetic, contains a set of body parts on which it should not be rendered because of one of the above + * reasons. + */ + val hiddenParts: Map> = + (partsHiddenDueToProperty.asSequence() + partsHiddenDueToArmor.asSequence()) + .groupBy({ it.key }) { it.value } + .mapValues { it.value.flatten().toSet() } + + /** + * For each cosmetic, contains a set of bone ids which should not be rendered because another cosmetic has + * explicitly them to avoid conflicts. + * This is similar to [partsHiddenDueToProperty] except that it targets specific bones, rather than whole body parts. + * E.g. the backpacks remove the built-in backpack of the space suit + */ + val hiddenBones: Map> = cosmetics.values + .flatMap { it.cosmetic.properties.filterIsInstance() } + .groupByPropertyTargetId { it.data.hiddenBones } + + /** + * For each cosmetic, contains the user-configured positional offset (e.g. glasses can be adjusted to your skin). + */ + val positionAdjustments: Map = cosmetics.values.mapNotNull { cosmetic -> + cosmetic.setting() + ?.data?.let { + cosmetic.id to Vector3(it.x, it.y, it.z) + } + }.toMap() + + /** + * For each cosmetic, contains the user-configured side on which it should show. Most asymmetrical cosmetics allow + * the user to flip them to the other side to match their preference. + */ + val sides: Map = cosmetics.values.mapNotNull { cosmetic -> + cosmetic.settings.side?.let { cosmetic.id to it } + }.toMap() + + /** + * Root bone for each cosmetic with exclusions applied. + */ + val rootBones: Map = bedrockModels.values.associate { model -> + val cosmetic = model.cosmetic + val slot = cosmetic.type.slot + val renderExclusions = bedrockModels.filter { (otherCosmetic, _) -> + val otherSlot = otherCosmetic.type.slot + exclusionsAffectSlots[otherSlot]?.contains(slot) ?: false + }.flatMap { getSidedRenderExclusions(it.value) } + val modelClipper = ModelClipperImpl() + model.cosmetic.id to modelClipper.compute(model.rootBone, renderExclusions) + } + + /** + * The configuration for the skin masker. + * Cosmetics may provide masks (black-white image files) to be applied to the player's skin while equipped, so the + * skin doesn't render on top of the cosmetic. + * This object contains all the necessary information for which skin masked to apply and how to offset them. + */ + val skinMaskConfig = SkinMaskConfig(bedrockModels.map { (cosmetic, model) -> + val settings = cosmetics[cosmetic.type.slot]?.settings ?: emptyList() + val side = settings.side + ?: cosmetic.defaultSide + ?: Side.getDefaultSideOrNull(model.sideOptions) + + val assets = cosmetic.assets(settings) + val mask = assets.sidedSkinMasks[side] ?: assets.defaultSkinMask + val partSettings = mutableListOf() + + // Hidden needs to be added first so that they are detected and filter before applying any movements + hiddenParts[cosmetic.id]?.forEach { hiddenPart -> + partSettings.add(SkinPartSetting(hiddenPart, true, 0, 0, 0)) + } + + positionAdjustments[cosmetic.id]?.let { offset -> + for (offsetPart in EnumPart.values()) { + partSettings.add( + SkinPartSetting( + offsetPart, + false, + offset.x.toInt(), + offset.y.toInt(), + offset.z.toInt() + ) + ) + } + } + + mask to partSettings + }) + + /** + * Set of armor slot ids that currently have cosmetics occupying + */ + val partsEquipped: Set = bedrockModels.values.flatMap { model -> + model.propagateVisibilityToRootBone( + sides[model.cosmetic.id], + rootBones.getValue(model.cosmetic.id), + hiddenBones[model.cosmetic.id] ?: emptySet(), + EnumPart.values().toSet(), + ) + model.getBones(model.rootBone).filter { it.containsVisibleBoxes() } + .mapNotNull { EnumPart.fromBoneName(it.boxName) } + }.flatMap { it.armorSlotIds }.toSet() + + fun getPositionAdjustment(cosmetic: Cosmetic) = positionAdjustments[cosmetic.id] ?: Vector3() + + /** + * Calls [valueSelector] on each setting to extract the relevant data and then groups all these values by the + * target of the setting which they were extracted from. + */ + private fun List.groupByPropertyTargetId(valueSelector: (E) -> Iterable): Map> = + this + .flatMap { setting -> valueSelector(setting).map { setting.id!! to it } } + .groupBy({ it.first }) { it.second } + .mapValues { it.value.toSet() } + + private fun getSidedRenderExclusions(model: BedrockModel): Collection { + return model.boundingBoxes.let { map -> + val cosmetic = model.cosmetic + // FIXME this should probably be handled in a single place + val side = sides[cosmetic.id] ?: cosmetic.defaultSide ?: Side.getDefaultSideOrNull(model.sideOptions) + if (side != null) { + map.filter { it.second == null || it.second == side } + } else { + map + } + }.map { it.first } + } + + fun copyWithout(slot: CosmeticSlot): CosmeticsState { + val cosmetics = cosmetics.filterKeys { it != slot } + val bedrockModels = bedrockModels.filterKeys { it.type.slot != slot } + return CosmeticsState(skinType, cosmetics, bedrockModels, armor) + } + + companion object { + + private val exclusionsAffectSlots = mapOf( + CosmeticSlot.SHOES to setOf(CosmeticSlot.FULL_BODY, CosmeticSlot.PANTS), + CosmeticSlot.TOP to setOf(CosmeticSlot.PANTS), + CosmeticSlot.ARMS to setOf(CosmeticSlot.FULL_BODY, CosmeticSlot.TOP), + CosmeticSlot.FULL_BODY to setOf(CosmeticSlot.TOP, CosmeticSlot.PANTS), + ) + + @JvmField + val EMPTY = CosmeticsState("", emptyMap(), emptyMap(), emptySet()) + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/EquippedCosmetic.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/EquippedCosmetic.kt new file mode 100644 index 0000000..c5f2765 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/EquippedCosmetic.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics + +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import gg.essential.mod.cosmetics.settings.CosmeticSettings +import gg.essential.mod.cosmetics.settings.setting +import gg.essential.mod.cosmetics.settings.settings +import gg.essential.mod.cosmetics.settings.variant +import gg.essential.network.cosmetics.Cosmetic + +data class EquippedCosmetic( + val cosmetic: Cosmetic, + val settings: CosmeticSettings, +) { + val id: CosmeticId + get() = cosmetic.id + + inline fun setting(): T? = settings.setting() + inline fun settings(): List = settings.settings() + + val variant: String + get() = settings.variant ?: cosmetic.defaultVariantName +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/WearablesManager.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/WearablesManager.kt new file mode 100644 index 0000000..62de389 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/WearablesManager.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics + +import gg.essential.cosmetics.events.AnimationTarget +import gg.essential.mod.cosmetics.CosmeticSlot +import gg.essential.model.EnumPart +import gg.essential.model.ModelAnimationState +import gg.essential.model.ModelInstance +import gg.essential.model.RenderMetadata +import gg.essential.model.backend.PlayerPose +import gg.essential.model.backend.RenderBackend +import gg.essential.model.molang.MolangQueryEntity +import gg.essential.model.util.UMatrixStack +import gg.essential.network.cosmetics.Cosmetic + +class WearablesManager( + private val entity: MolangQueryEntity, + private val animationTargets: Set, + private val onAnimation: (Cosmetic, String) -> Unit, +) { + var state: CosmeticsState = CosmeticsState.EMPTY + private set + + var models: Map = emptyMap() + private set + + fun updateState(newState: CosmeticsState) { + val oldModels = models + + val newModels = + newState.bedrockModels + .map { (cosmetic, bedrockModel) -> + val wearable = oldModels[cosmetic] + if (wearable == null) { + ModelInstance(bedrockModel, entity, animationTargets) { onAnimation(cosmetic, it) } + } else { + wearable.switchModel(bedrockModel) + wearable + } + } + .associateBy { it.cosmetic } + + for ((cosmetic, model) in models.entries) { + if (newModels[cosmetic] != model) { + model.locator.isValid = false + } + } + state = newState + models = newModels + } + + fun resetModel(slot: CosmeticSlot) { + updateState(state.copyWithout(slot)) + } + + fun render( + matrixStack: UMatrixStack, + vertexConsumerProvider: RenderBackend.VertexConsumerProvider, + pose: PlayerPose, + skin: RenderBackend.Texture, + parts: Set = EnumPart.values().toSet(), + ) { + for ((_, model) in models) { + render(matrixStack, vertexConsumerProvider, model, pose, skin, parts) + } + } + + fun render( + matrixStack: UMatrixStack, + vertexConsumerProvider: RenderBackend.VertexConsumerProvider, + model: ModelInstance, + pose: PlayerPose, + skin: RenderBackend.Texture, + parts: Set = EnumPart.values().toSet(), + ) { + val cosmetic = model.cosmetic + + val renderMetadata = RenderMetadata( + pose, + skin, + 0, + 1 / 16f, + state.sides[cosmetic.id], + state.hiddenBones[cosmetic.id] ?: emptySet(), + state.getPositionAdjustment(cosmetic), + parts - state.hiddenParts.getOrDefault(cosmetic.id, emptySet()), + ) + model.render(matrixStack, vertexConsumerProvider, state.rootBones.getValue(cosmetic.id), renderMetadata) + } + + fun collectEvents(consumer: (ModelAnimationState.Event) -> Unit) { + for (model in models.values) { + val pendingEvents = model.animationState.pendingEvents + if (pendingEvents.isNotEmpty()) { + for (event in pendingEvents) { + consumer(event) + } + pendingEvents.clear() + } + } + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipper.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipper.kt new file mode 100644 index 0000000..8a5f98d --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipper.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.boxmask + +import gg.essential.model.Bone +import gg.essential.model.Box3 + +interface ModelClipper { + fun compute(bone: Bone, masks: List): Bone +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipperImpl.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipperImpl.kt new file mode 100644 index 0000000..3157d6d --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/boxmask/ModelClipperImpl.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.boxmask + +import gg.essential.model.Bone +import gg.essential.model.Box3 +import gg.essential.model.Face +import kotlin.math.min + +// TODO clean up +class ModelClipperImpl : ModelClipper { + override fun compute(bone: Bone, masks: List): Bone { + if (masks.isEmpty()) { + return bone + } + val modifiedBone = bone.deepCopy() + apply(modifiedBone, masks) + return modifiedBone + } + + private fun apply(bone: Bone, renderExclusions: List) { + for (cube in bone.cubeList) { + val iterator = cube.getQuadList().iterator() + val newFaces = mutableListOf() + //We detect if this face enters a region that we don't want to cosmetics inside of. + //If we detect that we are inside an excluded region, we will decrease the size of the face + //So that it reaches the edge but does not enter this region. There are 4 regions (A,B,C,D) + //That are created on intersection. These are shown in docs/cosmetic masking.jpeg + //Currently only the A region is implemented on the Y axis as that is the only one + //We need to support for cosmetics + while (iterator.hasNext()) { + val next = iterator.next() + val selfBox = Box3() + val vertices = next.vertexPositions + val a = vertices[0].vector3.clone() + val b = vertices[1].vector3.clone() + val c = vertices[2].vector3.clone() + val d = vertices[3].vector3.clone() + selfBox.setFromPoints(listOf(a, b, c, d)) + var matched = false + val intersectedFace = IntersectedFace(next, cube.mirror) + for (exclusion in renderExclusions) { + val intersect = selfBox.clone().intersect(exclusion) + + //Completely encased + if (intersect == selfBox) { + iterator.remove() + matched = false //A previous face might have only partially clipped but we want to remove it + break + } + if (!intersect.isEmpty()) { + matched = true + val xHeight = intersect.max.x - intersect.min.x + val zHeight = intersect.max.z - intersect.min.z + val minYIntersect = min(intersect.min.y, intersectedFace.aRegion.points[3].vector3.y) + if (xHeight == 0f) { + generateARegion(intersectedFace, minYIntersect) + } else if (zHeight == 0f) { + generateARegion(intersectedFace, minYIntersect) + } + } + } + if (matched) { + iterator.remove() + newFaces.addAll(intersectedFace.generateFaces()) + } + } + cube.getQuadList().addAll(newFaces) + } + for (childModel in bone.childModels) { + apply(childModel, renderExclusions) + } + } + + private fun generateARegion(intersectedFace: IntersectedFace, minYIntersect: Float) { + intersectedFace.aRegion.points[2].vector3.y = minYIntersect + intersectedFace.aRegion.points[3].vector3.y = minYIntersect + val texY = intersectedFace.aRegion.points[1].texturePositionY + (minYIntersect - intersectedFace.aRegion.points[0].vector3.y) / intersectedFace.aRegion.spacialYDistance * intersectedFace.aRegion.textureYDistance + intersectedFace.aRegion.points[2].texturePositionY = texY + intersectedFace.aRegion.points[3].texturePositionY = texY + } + + //Render regions for intersected faces + private enum class EnumRegion { + A, B, C, D + } + + //Holds all render regions + private class IntersectedFace(base: Face, mirror: Boolean) { + val aRegion = FaceRegion(EnumRegion.A, base, mirror) + + fun generateFaces(): List { + return listOf(aRegion.toFace()) + } + } + + //Contains data about what the face modified due to clipping + private class FaceRegion(region: EnumRegion, base: Face, mirror: Boolean) { + var points = base.vertexPositions.map { it.copy() }.toTypedArray() + var spacialYDistance = 0f + var textureYDistance = 0f + + init { + if (mirror) flipFace() + if (region == EnumRegion.A) { + spacialYDistance = points[2].vector3.y - points[1].vector3.y + textureYDistance = points[2].texturePositionY - points[1].texturePositionY + } + } + + fun flipFace() { + points = points.mapIndexed { i, _ -> + points[points.size - i - 1] + }.toTypedArray() + } + + fun toFace(): Face { + return Face(points.map { it.copy() }.toTypedArray()) + } + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEvent.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEvent.kt new file mode 100644 index 0000000..e966642 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEvent.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.events + +import gg.essential.model.BedrockModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AnimationEvent( + val type: AnimationEventType? = null, + val target: AnimationTarget? = null, + val name: String, + @SerialName("on_complete") + val onComplete: AnimationEvent? = null, + val probability: Float = 1f, + val skips: Int = 0, + val loops: Int = 0, + val priority: Int = 0, +) { + fun getTotalTime(model: BedrockModel, whenLooping: Float = Float.POSITIVE_INFINITY): Float { + return if (loops == 0) { + whenLooping + } else { + val animation = model.getAnimationByName(name) ?: return 0f + animation.animationLength * loops + (onComplete?.getTotalTime(model, whenLooping) ?: 0f) + } + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEventType.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEventType.kt new file mode 100644 index 0000000..b234360 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationEventType.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.events + +enum class AnimationEventType { + EQUIP, + JOIN_WORLD, + LEAVE_WORLD, + JUMP_START, + JUMP_END, + WALK_START, + WALK_END, + SWING, + SNEAK_START, + SNEAK_END, + FLY_ON, + FLY_OFF, + FLY_MOVE_START, + FLY_MOVE_END, + ON_DAMAGE, + TICK, + IDLE, + TEXTURE_ANIMATION_START, + EMOTE, +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationTarget.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationTarget.kt new file mode 100644 index 0000000..ffa9a50 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/events/AnimationTarget.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.events + +enum class AnimationTarget { + SELF, + OTHERS, + ALL, +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinMaskConfig.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinMaskConfig.kt new file mode 100644 index 0000000..1b9fc58 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinMaskConfig.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.skinmask + +import gg.essential.mod.cosmetics.CosmeticAssets + +data class SkinMaskConfig( + val masks: List>>, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinPartSetting.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinPartSetting.kt new file mode 100644 index 0000000..26245fa --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/skinmask/SkinPartSetting.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.skinmask + +import gg.essential.model.EnumPart + +data class SkinPartSetting( + val part: EnumPart, + val isHidden: Boolean, + val shiftX: Int, + val shiftY: Int, + val shiftZ: Int, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/EssentialAnimationSystem.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/EssentialAnimationSystem.kt new file mode 100644 index 0000000..e073912 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/EssentialAnimationSystem.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.state + +import gg.essential.cosmetics.events.AnimationEvent +import gg.essential.cosmetics.events.AnimationEventType +import gg.essential.cosmetics.events.AnimationTarget +import gg.essential.model.BedrockModel +import gg.essential.model.ModelAnimationState +import gg.essential.model.molang.MolangQueryEntity +import kotlin.random.Random + +class EssentialAnimationSystem( + private val bedrockModel: BedrockModel, + private val entity: MolangQueryEntity, + private val animationState: ModelAnimationState, + private val textureAnimationSync: TextureAnimationSync, + private val animationTargets: Set, + private val onAnimation: (String) -> Unit, +) { + private val ongoingAnimations = mutableSetOf() + private val animationStates = AnimationEffectStates() + + private class AnimationEffectStates { + var skips = HashMap() + } + + private var lastFrame = 0f + + init { + processEvent(AnimationEventType.IDLE) + processEvent(AnimationEventType.EQUIP) + processEvent(AnimationEventType.EMOTE) + } + + fun updateAnimationState() { + val onComplete = mutableListOf() + ongoingAnimations.removeAll { ongoingAnimation: AnimationEvent -> + for (animationState in animationState.active) { + if (animationState.animation.name == ongoingAnimation.name && ongoingAnimation.loops > 0) { + val remove = animationState.animTime > animationState.animation.animationLength * ongoingAnimation.loops + if (remove && ongoingAnimation.onComplete != null) { + onComplete.add(ongoingAnimation.onComplete) + } + return@removeAll remove + } + } + false + } + ongoingAnimations.addAll(onComplete) + + val highestPriority = highestPriority + animationState.active.removeAll { animationState: ModelAnimationState.AnimationState -> + val animationByName = getAnimationByName(animationState.animation.name) + animationByName == null || animationByName !== highestPriority + } + if (animationState.active.isEmpty() && highestPriority != null) { + val animation = bedrockModel.getAnimationByName(highestPriority.name) + if (animation != null) { + animationState.startAnimation(animation) + } + } + } + + private val highestPriority: AnimationEvent? + get() = ongoingAnimations.maxByOrNull { obj: AnimationEvent -> obj.priority } + + private fun getAnimationByName(name: String): AnimationEvent? { + for (ongoingAnimation in ongoingAnimations) { + if (ongoingAnimation.name == name) { + return ongoingAnimation + } + } + return null + } + + fun processEvent(type: AnimationEventType) { + val animationEvents = bedrockModel.animationEvents + val highestPriority = highestPriority + val priority = highestPriority?.priority ?: 0 + var needsUpdate = false + for (event in animationEvents) { + if (priority > event.priority || event.type != type) continue + if (event.target != AnimationTarget.ALL && event.target !in animationTargets) { + continue + } + if (event.skips != 0) { + val i = (animationStates.skips[event] ?: 0) + 1 + animationStates.skips[event] = i + if (i % event.skips != 0) { + continue + } + } + if (!handleProbability(event)) continue + if (event.target != AnimationTarget.SELF) { + onAnimation(event.name) + } + ongoingAnimations.add(event) + needsUpdate = true + } + if (needsUpdate) { + updateAnimationState() + } + } + + fun fireTriggerFromAnimation(animationName: String) { + if (animationName == "texture_start") { + textureAnimationSync.syncTextureStart() + return + } + for (animationEvent in bedrockModel.animationEvents) { + if (animationEvent.name == animationName) { + ongoingAnimations.add(animationEvent) + updateAnimationState() + break + } + } + } + + private fun handleProbability(event: AnimationEvent): Boolean { + return event.probability > Random.nextDouble() + } + + fun maybeFireTextureAnimationStartEvent() { + val totalFrames = bedrockModel.textureFrameCount + val frame: Int = (entity.lifeTime * BedrockModel.TEXTURE_ANIMATION_FPS).toInt() + if (frame % totalFrames < lastFrame) { + onAnimation("texture_start") + processEvent(AnimationEventType.TEXTURE_ANIMATION_START) + } + lastFrame = (frame % totalFrames).toFloat() + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/TextureAnimationSync.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/TextureAnimationSync.kt new file mode 100644 index 0000000..bbd7818 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/TextureAnimationSync.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.state + +import gg.essential.model.BedrockModel +import gg.essential.model.util.now +import kotlin.math.abs + +class TextureAnimationSync(private val textureFrameCount: Int) { + + private var mostRecentLifetime = 0f + private var mostRecentSync: Long = 0 + private val animationOffsets = mutableListOf() + private var currentLifetimeOffset = 0f + private var lastTextureAdjustment = 0f + + /** + * Interpolate lifetimeOffset based on the last 3 syncs + */ + fun getAdjustedLifetime(lifetime: Float): Float { + mostRecentSync = now().toEpochMilli() + mostRecentLifetime = lifetime + if (animationOffsets.isEmpty()) { + return lifetime + } + var totalValues = 0f + var totalEntries = 0f + for (animationOffset in animationOffsets) { + totalValues += animationOffset + totalEntries++ + } + val targetOffsetTime = totalValues / totalEntries + val totalAnimationTime = textureFrameCount / BedrockModel.TEXTURE_ANIMATION_FPS + + // See if its better to speed up or slow down to get in sync + val deltaTimeBackwards = targetOffsetTime - (currentLifetimeOffset + totalAnimationTime) + val deltaTimeForwards = targetOffsetTime - currentLifetimeOffset + + // Make an adjustment every tick instead of every frame + if (lifetime - lastTextureAdjustment > .05) { + val timeAdjustment = if (abs(deltaTimeBackwards) < abs(deltaTimeForwards)) { + deltaTimeBackwards.coerceIn(-0.01f, 0.01f) + } else { + deltaTimeForwards.coerceIn(-0.01f, 0.01f) + } + currentLifetimeOffset += timeAdjustment + lastTextureAdjustment = lifetime + } + return lifetime + currentLifetimeOffset + } + + fun syncTextureStart() { + val totalFrames = textureFrameCount + val lifeTime = (now().toEpochMilli() - mostRecentSync) / 1000f + mostRecentLifetime + val frame = (lifeTime * BedrockModel.TEXTURE_ANIMATION_FPS).toInt() + val completedFrames = frame % totalFrames + val targetLifetimeOffset = (totalFrames - completedFrames) / BedrockModel.TEXTURE_ANIMATION_FPS + if (animationOffsets.size > 3) { + animationOffsets.removeFirst() + } + animationOffsets.add(targetLifetimeOffset) + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/WearableLocator.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/WearableLocator.kt new file mode 100644 index 0000000..8aba537 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/state/WearableLocator.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics.state + +import gg.essential.model.ParticleSystem + +/** A wrapper which becomes invalid when this particular cosmetic instance is unequipped. */ +class WearableLocator(private val parent: ParticleSystem.Locator) : ParticleSystem.Locator by parent { + private var wearableIsValid = true + override var isValid: Boolean + get() = parent.isValid && wearableIsValid + set(value) { wearableIsValid = value } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/types.kt b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/types.kt new file mode 100644 index 0000000..9c7dd77 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/cosmetics/types.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.cosmetics + +typealias CosmeticId = String +typealias CosmeticTypeId = String +typealias CosmeticCategoryId = String +typealias BoneId = String +typealias CosmeticBundleId = String +typealias FeaturedPageWidth = Int +typealias FeaturedPageCollectionId = String +typealias SkinId = String diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/EssentialAsset.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/EssentialAsset.kt new file mode 100644 index 0000000..32a91b9 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/EssentialAsset.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod + +import kotlinx.serialization.Serializable + +@Serializable +data class EssentialAsset( + val url: String, + val checksum: String, +) { + companion object { + val EMPTY = EssentialAsset("data:,", "d41d8cd98f00b204e9800998ecf8427e") + val EMPTY_JSON_OBJ = EssentialAsset("data:,{}", "99914b932bd37a50b983c5e7c90ae93b") + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/Model.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/Model.kt new file mode 100644 index 0000000..23d53ca --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/Model.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Skin model. + */ +@Serializable +enum class Model( + /** The internal name used by Minecraft to refer to this Model. */ + val type: String, + /** The name used by Mojang services to refer to this Model (case insensitive). */ + val variant: String, +) { + @SerialName("classic") + STEVE("default", "classic"), + @SerialName("slim") + ALEX("slim", "slim"); + + companion object { + @JvmStatic + fun byType(str: String) = values().find { it.type == str } + + @JvmStatic + fun byTypeOrDefault(str: String) = byType(str) ?: STEVE + + @JvmStatic + fun byVariant(str: String) = values().find { it.variant.equals(str, ignoreCase = true) } + + @JvmStatic + fun byVariantOrDefault(str: String) = byVariant(str) ?: STEVE + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/Skin.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/Skin.kt new file mode 100644 index 0000000..b33f067 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/Skin.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod + +import kotlinx.serialization.Serializable +import java.util.* + +@Serializable +data class Skin( + val hash: String, + val model: Model, +) { + + val url = String.format(Locale.ROOT, SKIN_URL, hash) + + companion object { + + const val SKIN_URL = "https://textures.minecraft.net/texture/%s" + + @JvmStatic + fun fromUrl(url: String, model: Model) = + Skin(hashFromUrl(url), model) + + @JvmStatic + fun hashFromUrl(url: String): String = + url.split("/").lastOrNull() ?: "" + + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/asset/AssetProvider.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/asset/AssetProvider.kt new file mode 100644 index 0000000..863d09f --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/asset/AssetProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.asset + +import gg.essential.mod.EssentialAsset + +interface AssetProvider { + suspend fun getBytes(asset: EssentialAsset): ByteArray + + suspend fun getString(asset: EssentialAsset): String = getBytes(asset).decodeToString() +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CapeModel.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CapeModel.kt new file mode 100644 index 0000000..01df3cd --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CapeModel.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.model.BedrockModel +import gg.essential.model.backend.RenderBackend +import gg.essential.model.file.ModelFile +import gg.essential.model.util.now +import gg.essential.network.cosmetics.Cosmetic + +object CapeModel { + private val capeModelJson = + """ + { + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.cape", + "texture_width": 64, + "texture_height": 32, + "visible_bounds_width": 3, + "visible_bounds_height": 4, + "visible_bounds_offset": [0, 1, 0] + }, + "bones": [ + { + "name": "root", + "pivot": [0, 0, 0] + }, + { + "name": "cape", + "parent": "root", + "pivot": [0, 24, 2.5], + "rotation": [-6, 180, 0], + "cubes": [ + {"origin": [-5, 8, 2], "size": [10, 16, 1], "uv": [0, 0]} + ] + } + ] + } + ] + } + """.trimIndent() + + val capeModelFile = ModelFile.parse(capeModelJson) + + private val type = CosmeticType("CAPE", CosmeticSlot.CAPE, emptyMap(), emptyMap()) + private val cosmetic = + Cosmetic( + "CAPE", + type, + CosmeticTier.COMMON, + emptyMap(), + emptyMap(), + emptyList(), + -1, + emptyMap(), + emptySet(), + now(), + null, + null, + emptyMap(), + emptyMap(), + 0, + ) + + private val dummyTexture = object : RenderBackend.Texture { + override val width: Int + get() = 64 + override val height: Int + get() = 32 + } + + val capeModel = BedrockModel(cosmetic, "", capeModelFile, null, emptyList(), null, dummyTexture) + init { + capeModel.texture = null + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticAssets.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticAssets.kt new file mode 100644 index 0000000..95a430e --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticAssets.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.mod.EssentialAsset +import gg.essential.model.Side +import kotlinx.serialization.Serializable + +@Serializable +data class CosmeticAssets(val files: Map) { + val thumbnail: EssentialAsset? = files["thumbnail.png"] + val texture: EssentialAsset? = files["texture.png"] + val geometry: Geometry = Geometry(files["geometry.steve.json"] ?: EssentialAsset.EMPTY, files["geometry.alex.json"]) + val animations: EssentialAsset? = files["animations.json"] + val defaultSkinMask: SkinMask = SkinMask(files["skin_mask.steve.png"], files["skin_mask.alex.png"]) + val sidedSkinMasks: Map = Side.values().associateWith { side -> + val sideId = side.name.lowercase() + SkinMask(files["skin_mask.steve.$sideId.png"], files["skin_mask.alex.$sideId.png"]) + } + val settings: EssentialAsset? = files["settings.json"] + val particles: List = + files.mapNotNull { (path, asset) -> if (path.startsWith("particles/") && path.endsWith(".json")) asset else null } + val soundDefinitions: EssentialAsset? + get() = files["sounds/sound_definitions.json"] + + @Serializable + data class Geometry( + val steve: EssentialAsset, + val alex: EssentialAsset?, + ) + + @Serializable + data class SkinMask( + val steve: EssentialAsset?, + val alex: EssentialAsset?, + ) +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticBundle.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticBundle.kt new file mode 100644 index 0000000..89ccc2c --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticBundle.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.cosmetics.CosmeticId +import gg.essential.mod.Model +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import kotlinx.serialization.Serializable + +@Serializable +data class CosmeticBundle( + val id: String, + val name: String, + val tier: CosmeticTier, + val discountPercent: Float, + var skin: Skin, + val cosmetics: Map, + val settings: Map>, +) { + @Serializable + data class Skin( + val hash: String, + val model: Model, + val name: String? = null + ) { + + constructor(skin: gg.essential.mod.Skin, name: String? = null) : this(skin.hash, skin.model, name) + + fun toMod() = gg.essential.mod.Skin(hash, model) + + } + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticCategory.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticCategory.kt new file mode 100644 index 0000000..8574f49 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticCategory.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +@file:UseSerializers(InstantAsMillisSerializer::class) + +package gg.essential.mod.cosmetics + +import gg.essential.mod.EssentialAsset +import gg.essential.model.util.Instant +import gg.essential.model.util.InstantAsMillisSerializer +import gg.essential.model.util.now +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +@Serializable +data class CosmeticCategory( + val id: String, + val icon: EssentialAsset, + val displayNames: Map, + val compactNames: Map, + val descriptions: Map, + val slots: Set, + val tags: Set, + val order: Int, + val availableAfter: Instant? = null, + val availableUntil: Instant? = null, +) { + fun isAvailable(at: Instant = now()): Boolean { + val isAvailableAfterNow = availableAfter != null && availableAfter < at + val isAvailableBeforeNow = availableUntil == null || availableUntil > at + return isAvailableAfterNow && isAvailableBeforeNow + } + + fun isEmoteCategory() = tags.contains(EMOTE_CATEGORY_TAG) + + fun isHidden() = tags.contains(HIDDEN_CATEGORY_TAG) + + companion object { + const val EMOTE_CATEGORY_TAG = "EMOTE" + const val HIDDEN_CATEGORY_TAG = "HIDDEN" + } + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticOutfit.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticOutfit.kt new file mode 100644 index 0000000..1f21b71 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticOutfit.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.cosmetics.SkinId +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import gg.essential.model.util.Instant + +data class CosmeticOutfit( + val id: String, + val name: String, + val skin: OutfitSkin?, + val skinId: SkinId?, + val equippedCosmetics: Map, + val cosmeticSettings: Map>, + val favoritedSince: Instant?, + val createdAt: Instant, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticSlot.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticSlot.kt new file mode 100644 index 0000000..5df318c --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticSlot.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.concurrent.ConcurrentHashMap + +@Serializable(with = CosmeticSlot.Serializer::class) +data class CosmeticSlot private constructor(val id: String) { + companion object { + private val values = mutableListOf() + private val entries = ConcurrentHashMap() + + fun of(id: String): CosmeticSlot { + return entries.computeIfAbsent(id, ::CosmeticSlot) + } + + private fun make(id: String): CosmeticSlot { + return of(id).also { values.add(it) } + } + + @JvmStatic + fun values(): List = values + + @JvmField val BACK = make("BACK") + @JvmField val EARS = make("EARS") + @JvmField val FACE = make("FACE") + @JvmField val FULL_BODY = make("FULL_BODY") + @JvmField val HAT = make("HAT") + @JvmField val PET = make("PET") + @JvmField val TAIL = make("TAIL") + @JvmField val ARMS = make("ARMS") + @JvmField val SHOULDERS = make("SHOULDERS") + @JvmField val SUITS = make("SUITS") + @JvmField val SHOES = make("SHOES") + @JvmField val PANTS = make("PANTS") + @JvmField val WINGS = make("WINGS") + @JvmField val EFFECT = make("EFFECT") + @JvmField val CAPE = make("CAPE") + @JvmField val EMOTE = make("EMOTE") + @JvmField val ICON = make("ICON") + @JvmField val TOP = make("TOP") + @JvmField val ACCESSORY = make("ACCESSORY") + @JvmField val HEAD = make("HEAD") + } + + internal object Serializer : KSerializer { + private val inner = String.serializer() + override val descriptor: SerialDescriptor = inner.descriptor + override fun deserialize(decoder: Decoder) = of(decoder.decodeSerializableValue(inner)) + override fun serialize(encoder: Encoder, value: CosmeticSlot) = encoder.encodeSerializableValue(inner, value.id) + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticTier.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticTier.kt new file mode 100644 index 0000000..1e18b5e --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticTier.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import kotlinx.serialization.Serializable + +@Serializable +enum class CosmeticTier { + COMMON, + UNCOMMON, + RARE, + EPIC, + LEGENDARY, +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticType.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticType.kt new file mode 100644 index 0000000..10f9ee4 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticType.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import kotlinx.serialization.Serializable + +@Serializable +data class CosmeticType( + val id: String, + val slot: CosmeticSlot, + val displayNames: Map, + val skinLayers: Map, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticsSubject.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticsSubject.kt new file mode 100644 index 0000000..b4fed28 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/CosmeticsSubject.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.cosmetics.events.AnimationTarget +import gg.essential.model.EnumPart +import gg.essential.model.molang.MolangQueryEntity + +/** Information on the subject of a set of cosmetics. */ +data class CosmeticsSubject( + /** The entity used to drive animations */ + val entity: MolangQueryEntity, + /** Skin type of the subject (Minecraft-internal naming, "default" or "slim") */ + val skinType: String = "default", + /** Parts of the subject that are covered by armor */ + val armor: Set = emptySet(), + /** Types of animations to play on this subject (ALL is always included) */ + val animationTargets: Set = + setOf(AnimationTarget.SELF, AnimationTarget.OTHERS), +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/EmoteWheelPage.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/EmoteWheelPage.kt new file mode 100644 index 0000000..85020a7 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/EmoteWheelPage.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.model.util.Instant + +data class EmoteWheelPage( + val id: String, + val createdAt: Instant, + var isSelected: Boolean, + val slots: MutableList, +) \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/OutfitSkin.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/OutfitSkin.kt new file mode 100644 index 0000000..4e3bd25 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/OutfitSkin.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.mod.Model +import gg.essential.mod.Skin + +data class OutfitSkin( + val skin: Skin, + val locked: Boolean, +) { + constructor( + hash: String, + model: Model, + locked: Boolean, + ) : this(Skin(hash, model), locked) + + fun serialize(): String { + val (hash, model) = skin + val modelStr = model.ordinal + val lockedStr = if (locked) "1" else "0" + return "$modelStr;$hash;$lockedStr" + } + + companion object { + @JvmStatic + fun deserialize(string: String?): OutfitSkin? { + val parts = (string ?: return null) + .split(";") + .toTypedArray() + if (parts.size < 2) return null + return OutfitSkin( + model = if (parts[0] == "1") Model.ALEX else Model.STEVE, + hash = parts[1], + locked = parts.size >= 3 && parts[2] == "1", + ) + } + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/PlayerModel.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/PlayerModel.kt new file mode 100644 index 0000000..7690890 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/PlayerModel.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.model.BedrockModel +import gg.essential.model.file.AnimationFile +import gg.essential.model.file.ModelFile +import gg.essential.model.util.now +import gg.essential.network.cosmetics.Cosmetic + +object PlayerModel { + private val cosmeticType = + CosmeticType("PLAYER", CosmeticSlot.FULL_BODY, emptyMap(), emptyMap()) + + private val cosmetic = + Cosmetic( + "PLAYER", + cosmeticType, + CosmeticTier.COMMON, + emptyMap(), + emptyMap(), + emptyList(), + -1, + emptyMap(), + emptySet(), + now(), + null, + null, + emptyMap(), + emptyMap(), + 0, + ) + + private val steveModelJson = + """ + { + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.steve", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 3, + "visible_bounds_height": 3.5, + "visible_bounds_offset": [0, 1.25, 0] + }, + "bones": [ + { + "name": "root", + "pivot": [0, 0, 0] + }, + { + "name": "Head", + "parent": "root", + "pivot": [0, 24, 0], + "cubes": [ + {"origin": [-4, 24, -4], "size": [8, 8, 8], "uv": [0, 0]} + ] + }, + { + "name": "hat", + "parent": "Head", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-4, 24, -4], "size": [8, 8, 8], "inflate": 0.5, "uv": [32, 0]} + ] + }, + { + "name": "Body", + "parent": "root", + "pivot": [0, 12, 0], + "cubes": [ + {"origin": [-4, 12, -2], "size": [8, 12, 4], "uv": [16, 16]} + ] + }, + { + "name": "jacket", + "parent": "Body", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-4, 12, -2], "size": [8, 12, 4], "inflate": 0.25, "uv": [16, 32]} + ] + }, + { + "name": "LeftLeg", + "parent": "root", + "pivot": [1.9, 12, 0], + "cubes": [ + {"origin": [-0.1, 0, -2], "size": [4, 12, 4], "uv": [16, 48]} + ] + }, + { + "name": "left_pants_leg", + "parent": "LeftLeg", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-0.1, 0, -2], "size": [4, 12, 4], "inflate": 0.25, "uv": [0, 48]} + ] + }, + { + "name": "RightLeg", + "parent": "root", + "pivot": [-1.9, 12, 0], + "cubes": [ + {"origin": [-3.9, 0, -2], "size": [4, 12, 4], "uv": [0, 16]} + ] + }, + { + "name": "right_pants_leg", + "parent": "RightLeg", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-3.9, 0, -2], "size": [4, 12, 4], "inflate": 0.25, "uv": [0, 32]} + ] + }, + { + "name": "LeftArm", + "parent": "root", + "pivot": [5, 22, 0], + "cubes": [ + {"origin": [4, 12, -2], "size": [4, 12, 4], "uv": [32, 48]} + ] + }, + { + "name": "left_sleeve", + "parent": "LeftArm", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [4, 12, -2], "size": [4, 12, 4], "inflate": 0.25, "uv": [48, 48]} + ] + }, + { + "name": "RightArm", + "parent": "root", + "pivot": [-5, 22, 0], + "cubes": [ + {"origin": [-8, 12, -2], "size": [4, 12, 4], "uv": [40, 16]} + ] + }, + { + "name": "right_sleeve", + "parent": "RightArm", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-8, 12, -2], "size": [4, 12, 4], "inflate": 0.25, "uv": [40, 32]} + ] + } + ] + } + ] + } + """.trimIndent() + private val alexModelJson = + """ + { + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.alex", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 3, + "visible_bounds_height": 3.5, + "visible_bounds_offset": [0, 1.25, 0] + }, + "bones": [ + { + "name": "root", + "pivot": [0, 0, 0] + }, + { + "name": "Head", + "parent": "root", + "pivot": [0, 24, 0], + "cubes": [ + {"origin": [-4, 24, -4], "size": [8, 8, 8], "uv": [0, 0]} + ] + }, + { + "name": "hat", + "parent": "Head", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-4, 24, -4], "size": [8, 8, 8], "inflate": 0.5, "uv": [32, 0]} + ] + }, + { + "name": "Body", + "parent": "root", + "pivot": [0, 12, 0], + "cubes": [ + {"origin": [-4, 12, -2], "size": [8, 12, 4], "uv": [16, 16]} + ] + }, + { + "name": "jacket", + "parent": "Body", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-4, 12, -2], "size": [8, 12, 4], "inflate": 0.25, "uv": [16, 32]} + ] + }, + { + "name": "LeftLeg", + "parent": "root", + "pivot": [1.9, 12, 0], + "cubes": [ + {"origin": [-0.1, 0, -2], "size": [4, 12, 4], "uv": [16, 48]} + ] + }, + { + "name": "left_pants_leg", + "parent": "LeftLeg", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-0.1, 0, -2], "size": [4, 12, 4], "inflate": 0.25, "uv": [0, 48]} + ] + }, + { + "name": "RightLeg", + "parent": "root", + "pivot": [-1.9, 12, 0], + "cubes": [ + {"origin": [-3.9, 0, -2], "size": [4, 12, 4], "uv": [0, 16]} + ] + }, + { + "name": "right_pants_leg", + "parent": "RightLeg", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-3.9, 0, -2], "size": [4, 12, 4], "inflate": 0.25, "uv": [0, 32]} + ] + }, + { + "name": "LeftArm", + "parent": "root", + "pivot": [5, 22, 0], + "cubes": [ + {"origin": [4, 12, -2], "size": [3, 12, 4], "uv": [32, 48]} + ] + }, + { + "name": "left_sleeve", + "parent": "LeftArm", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [4, 12, -2], "size": [3, 12, 4], "inflate": 0.25, "uv": [48, 48]} + ] + }, + { + "name": "RightArm", + "parent": "root", + "pivot": [-5, 22, 0], + "cubes": [ + {"origin": [-7, 12, -2], "size": [3, 12, 4], "uv": [40, 16]} + ] + }, + { + "name": "right_sleeve", + "parent": "RightArm", + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-7, 12, -2], "size": [3, 12, 4], "inflate": 0.25, "uv": [40, 32]} + ] + } + ] + } + ] + } + """.trimIndent() + private val animationJson = """ + { + "format_version": "1.8.0", + "animations": { + "animation.player.idle": { + "loop": true, + "animation_length": 1, + "bones": { + "RightArm": { + "rotation": { + "0.0": [ + "(math.sin(query.life_time * 20 * 0.067 / math.pi * 180) * 0.05) / math.pi * 180", + 0, + "(math.cos(query.life_time * 20 * 0.09 / math.pi * 180) * 0.05 + 0.05) / math.pi * 180" + ] + } + }, + "LeftArm": { + "rotation": { + "0.0": [ + "-(math.sin(query.life_time * 20 * 0.067 / math.pi * 180) * 0.05) / math.pi * 180", + 0, + "-(math.cos(query.life_time * 20 * 0.09 / math.pi * 180) * 0.05 + 0.05) / math.pi * 180" + ] + } + } + } + } + } , + "triggers": [ + { + "type": "IDLE", + "target": "ALL", + "probability": 1, + "name": "animation.player.idle", + "skips": 0, + "priority": 1, + "loops": 0 + } + ] + } + """.trimIndent() + + private val steveModelFile = ModelFile.parse(steveModelJson) + private val alexModelFile = ModelFile.parse(alexModelJson) + + private val animationFile = AnimationFile.parse(animationJson) + + val steveBedrockModel = BedrockModel(cosmetic, "", steveModelFile, animationFile, emptyList(), null, null) + val alexBedrockModel = BedrockModel(cosmetic, "", alexModelFile, animationFile, emptyList(), null, null) +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/SkinLayer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/SkinLayer.kt new file mode 100644 index 0000000..1b074b3 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/SkinLayer.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +enum class SkinLayer { + CAPE, + JACKET, + LEFT_SLEEVE, + RIGHT_SLEEVE, + LEFT_PANTS_LEG, + RIGHT_PANTS_LEG, + HAT, +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/capeDisabled.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/capeDisabled.kt new file mode 100644 index 0000000..e82f271 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/capeDisabled.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics + +import gg.essential.mod.EssentialAsset +import gg.essential.model.util.now +import gg.essential.network.cosmetics.Cosmetic + +const val CAPE_DISABLED_COSMETIC_ID = "CAPE_DISABLED" + +@JvmField +val CAPE_DISABLED_COSMETIC = Cosmetic( + CAPE_DISABLED_COSMETIC_ID, + CosmeticType("CAPE", CosmeticSlot.CAPE, emptyMap(), emptyMap()), + CosmeticTier.COMMON, + emptyMap(), + mapOf("geometry.steve.json" to EssentialAsset.EMPTY_JSON_OBJ), + emptyList(), + -1, + emptyMap(), + emptySet(), + now(), + null, + null, + emptyMap(), + emptyMap(), + 0, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/CosmeticsDatabase.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/CosmeticsDatabase.kt new file mode 100644 index 0000000..625e99a --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/CosmeticsDatabase.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.database + +import gg.essential.cosmetics.CosmeticBundleId +import gg.essential.cosmetics.CosmeticCategoryId +import gg.essential.cosmetics.CosmeticId +import gg.essential.cosmetics.CosmeticTypeId +import gg.essential.cosmetics.FeaturedPageCollectionId +import gg.essential.mod.cosmetics.CosmeticBundle +import gg.essential.mod.cosmetics.CosmeticCategory +import gg.essential.mod.cosmetics.CosmeticType +import gg.essential.mod.cosmetics.featured.FeaturedPageCollection +import gg.essential.network.cosmetics.Cosmetic + +/** Provides access to cosmetics metadata */ +interface CosmeticsDatabase { + suspend fun getCategory(id: CosmeticCategoryId): CosmeticCategory? + suspend fun getCategories(): List + + suspend fun getType(id: CosmeticTypeId): CosmeticType? + suspend fun getTypes(): List + + suspend fun getCosmeticBundle(id: CosmeticBundleId): CosmeticBundle? // rename to getBundle() when removing feature flag + suspend fun getCosmeticBundles(): List // rename to getBundles() when removing feature flag + + suspend fun getFeaturedPageCollection(id: FeaturedPageCollectionId): FeaturedPageCollection? + suspend fun getFeaturedPageCollections(): List + + suspend fun getCosmetic(id: CosmeticId): Cosmetic? + suspend fun getCosmetics(): List + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt new file mode 100644 index 0000000..6c24438 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt @@ -0,0 +1,1139 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +@file:UseSerializers(InstantAsIso8601Serializer::class) + +package gg.essential.mod.cosmetics.database + +import gg.essential.cosmetics.CosmeticBundleId +import gg.essential.cosmetics.CosmeticCategoryId +import gg.essential.cosmetics.CosmeticId +import gg.essential.cosmetics.CosmeticTypeId +import gg.essential.cosmetics.FeaturedPageCollectionId +import gg.essential.cosmetics.FeaturedPageWidth +import gg.essential.mod.EssentialAsset +import gg.essential.mod.Skin +import gg.essential.mod.cosmetics.CosmeticAssets +import gg.essential.mod.cosmetics.CosmeticBundle +import gg.essential.mod.cosmetics.CosmeticCategory +import gg.essential.mod.cosmetics.settings.CosmeticProperty +import gg.essential.mod.cosmetics.CosmeticSlot +import gg.essential.mod.cosmetics.CosmeticTier +import gg.essential.mod.cosmetics.CosmeticType +import gg.essential.mod.cosmetics.featured.FeaturedPage +import gg.essential.mod.cosmetics.featured.FeaturedPageCollection +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import gg.essential.model.Side +import gg.essential.model.util.Instant +import gg.essential.model.util.InstantAsIso8601Serializer +import gg.essential.model.util.base64Decode +import gg.essential.model.util.base64Encode +import gg.essential.model.util.md5Hex +import gg.essential.model.util.now +import gg.essential.network.cosmetics.Cosmetic +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.plus + +/** + * Loads cosmetics from a local clone of the cosmetics git repository. + * + * Works with a simple file system abstraction to allow it to work in the browser as well. + */ +class GitRepoCosmeticsDatabase( + /** + * If `false`, all cosmetics will be loaded during [addFiles], otherwise they will only be loaded as requested by + * [getCosmetic] or [getCosmetics] (which will load all cosmetics when called; to access already-loaded cosmetics, + * use [loadedCosmetics]). + * + * Categories and types are always loaded eagerly. + */ + private val lazy: Boolean, + /** + * Constructs an [EssentialAsset] for the given path. + * + * The default implementation simply encodes the content of the file as a base64 `data:` url. + */ + val assetFromPath: AssetBuilder = { _, read -> + val content = read() + EssentialAsset(dataUrlBase64Prefix + base64Encode(content), md5Hex(content)) + }, + /** + * Retrieves the content of a given asset. + * Used by the [computeChanges] methods to retrieve assets which have changed. + * The default implementation supports only base64 data: urls. + */ + val fetchAsset: suspend (EssentialAsset) -> ByteArray = { asset -> + val url = asset.url + if (!url.startsWith(dataUrlBase64Prefix)) { + throw UnsupportedOperationException("Can only fetch base64 data: urls") + } + base64Decode(url.removePrefix(dataUrlBase64Prefix)) + } +) : CosmeticsDatabase { + + private val files = mutableMapOf ByteArray>() + private val fileObservers = mutableMapOf() + private val folderObservers = mutableMapOf() + + private val categories = mutableMapOf() + private val types = mutableMapOf() + private val bundles = mutableMapOf() + private val featuredPageCollections = mutableMapOf() + private val cosmetics = mutableMapOf() + private val lazyCosmetics = mutableMapOf() + + private val categoryByPath = mutableMapOf() + private val typeByPath = mutableMapOf() + private val bundleByPath = mutableMapOf() + private val featuredPageCollectionByPath = mutableMapOf() + private val cosmeticByPath = mutableMapOf() + + val loadedCategories: Map + get() = categories + val loadedTypes: Map + get() = types + val loadedBundles: Map + get() = bundles + val loadedFeaturedPageCollections: Map + get() = featuredPageCollections + val loadedCosmetics: Map + get() = cosmetics + + suspend fun addFiles(files: Map ByteArray>): Changes { + val newPaths = files.mapKeys { Path.of(it.key) } + + this.files.putAll(newPaths) + + for ((file, read) in newPaths) { + if (file.str.endsWith(".category-metadata.json")) { + fileObservers.getOrPut(file, ::Observers).categories.add(file) + } + if (file.str.endsWith(".type-metadata.json")) { + fileObservers.getOrPut(file, ::Observers).types.add(file) + } + if (file.str.endsWith(".store-bundle-metadata.json")) { + fileObservers.getOrPut(file, ::Observers).bundles.add(file) + } + if (file.str.endsWith(".featured-page-metadata.json")) { + fileObservers.getOrPut(file, ::Observers).featuredPageCollections.add(file) + } + if (file.str.endsWith(".cosmetic-metadata.json")) { + if (lazy && fileObservers[file]?.cosmetics?.contains(file) != true) { + val metadata = json.decodeFromString(read().decodeToString()) + val id = metadata.override.id ?: file.parent.name.uppercase() + lazyCosmetics[id] = file + } else { + fileObservers.getOrPut(file, ::Observers).cosmetics.add(file) + } + } + } + + return updateFiles(newPaths.keys) + } + + suspend fun removeFiles(filesOrFolders: Set): Changes { + val removedFilesOrFolders = filesOrFolders.map { Path.of(it) } + val removedFiles = this.files.keys.filter { knownFile -> + knownFile in removedFilesOrFolders || removedFilesOrFolders.any { knownFile.isIn(it) } + } + + removedFiles.forEach { this.files.remove(it) } + + return updateFiles(removedFiles) + } + + private suspend fun updateFiles(files: Collection): Changes { + val categoriesChanged = mutableSetOf() + val typesChanged = mutableSetOf() + val cosmeticsChanged = mutableSetOf() + val bundlesChanged = mutableSetOf() + val featuredPagesCollectionsChanged = mutableSetOf() + + suspend fun updateObservers(observers: Observers) { + observers.categories.toList().mapNotNullTo(categoriesChanged) { path -> + val category = tryLoadCategory(path) + if (category != null) { + categories[category.id] = category + categoryByPath[path] = category.id + category.id + } else { + val categoryId = categoryByPath.remove(path) + if (categoryId != null) { + categories.remove(categoryId) + categoryId + } else { + null + } + } + } + + observers.types.toList().mapNotNullTo(typesChanged) { path -> + val type = tryLoadType(path) + if (type != null) { + updateTypeInCosmetics(types[type.id], type).forEach { cosmeticsChanged.add(it.id) } + types[type.id] = type + typeByPath[path] = type.id + type.id + } else { + val typeId = typeByPath.remove(path) + if (typeId != null) { + types.remove(typeId) + typeId + } else { + null + } + } + } + + observers.bundles.toList().mapNotNullTo(bundlesChanged) { path -> + val bundle = tryLoadBundle(path) + if (bundle != null) { + bundles[bundle.id] = bundle + bundleByPath[path] = bundle.id + bundle.id + } else { + val bundleId = bundleByPath.remove(path) + if (bundleId != null) { + bundles.remove(bundleId) + bundleId + } else { + null + } + } + } + + observers.featuredPageCollections.toList().mapNotNullTo(featuredPagesCollectionsChanged) { path -> + val featuredPageCollection = tryLoadFeaturedPageCollection(path) + if (featuredPageCollection != null) { + featuredPageCollections[featuredPageCollection.id] = featuredPageCollection + featuredPageCollectionByPath[path] = featuredPageCollection.id + featuredPageCollection.id + } else { + val width = featuredPageCollectionByPath.remove(path) + if (width != null) { + featuredPageCollections.remove(width) + width + } else { + null + } + } + } + + observers.cosmetics.toList().mapNotNullTo(cosmeticsChanged) { path -> + val cosmetic = tryLoadCosmetic(path) + if (cosmetic != null) { + cosmetics[cosmetic.id] = cosmetic + cosmeticByPath[path] = cosmetic.id + lazyCosmetics.remove(cosmetic.id) + cosmetic.id + } else { + val cosmeticId = cosmeticByPath.remove(path) + if (cosmeticId != null) { + cosmetics.remove(cosmeticId) + cosmeticId + } else { + null + } + } + } + } + + for (file in files) { + fileObservers[file]?.let { updateObservers(it) } + + var folder = file + while (!folder.isEmpty()) { + folderObservers[folder]?.let { updateObservers(it) } + folder = folder.parent + } + } + + return Changes( + categoriesChanged, + typesChanged, + cosmeticsChanged, + bundlesChanged, + featuredPagesCollectionsChanged, + ) + } + + private fun updateTypeInCosmetics(oldType: CosmeticType?, newType: CosmeticType): List { + return if (oldType != newType) { + val updatedCosmetics = cosmetics.values.mapNotNull { cosmetic -> + if (cosmetic.type.id == newType.id) { + cosmetic.copy(type = newType) + } else { + null + } + } + updatedCosmetics.forEach { cosmetics[it.id] = it } + updatedCosmetics + } else { + emptyList() + } + } + + private suspend fun tryLoadCategory(metadataFile: Path): CosmeticCategory? { + if (metadataFile !in files) return null + return try { + val fileAccess = FileAccessImpl(metadataFile) { categories } + fileAccess.loadCategory(metadataFile, assetFromPath) + } catch (e: Exception) { + Exception("Failed to load category at $metadataFile", e).printStackTrace() + null + } + } + + private suspend fun tryLoadType(metadataFile: Path): CosmeticType? { + if (metadataFile !in files) return null + return try { + val fileAccess = FileAccessImpl(metadataFile) { types } + fileAccess.loadType(metadataFile) + } catch (e: Exception) { + Exception("Failed to load type at $metadataFile", e).printStackTrace() + null + } + } + + private suspend fun tryLoadBundle(metadataFile: Path): CosmeticBundle? { + if (metadataFile !in files) return null + return try { + val fileAccess = FileAccessImpl(metadataFile) { bundles } + fileAccess.loadBundle(metadataFile) + } catch (e: Exception) { + Exception("Failed to load bundle at $metadataFile", e).printStackTrace() + null + } + } + + private suspend fun tryLoadFeaturedPageCollection(metadataFile: Path): FeaturedPageCollection? { + if (metadataFile !in files) return null + return try { + val fileAccess = FileAccessImpl(metadataFile) { featuredPageCollections } + fileAccess.loadFeaturedPageCollection(metadataFile) + } catch (e: Exception) { + Exception("Failed to load featured page collection at $metadataFile", e).printStackTrace() + null + } + } + + private suspend fun tryLoadCosmetic(metadataFile: Path): Cosmetic? { + if (metadataFile !in files) return null + return try { + val fileAccess = FileAccessImpl(metadataFile) { cosmetics } + fileAccess.loadCosmetic(metadataFile, assetFromPath) { typeId -> types[typeId] } + } catch (e: Exception) { + Exception("Failed to load cosmetic at $metadataFile", e).printStackTrace() + null + } + } + + /** + * Computes the file changes necessary to update the category with [id] to match the given [category] data. + * Returns a map of paths relative to the repository root with associated file data (or null if the file is to be + * deleted). + * + * If no such category exists, a new one is created. + * If the passed category data is `null`, the existing category with [id] (if any) will be deleted. + * + * Note that does by itself not apply these changes. It is the responsibility of the caller to apply the returned + * values to the real file system and to call [updateFiles] if they wish these changes to be reflected in the state + * of this [GitRepoCosmeticsDatabase]. + */ + suspend fun computeChanges(id: CosmeticCategoryId, category: CosmeticCategory?): Map { + val originalCategory = categories[id] + + val existingMetadataFile = categoryByPath.entries.firstNotNullOfOrNull { if (it.value == id) it.key else null } + val metadataFile = when { + existingMetadataFile != null -> existingMetadataFile + category != null -> Path.of("configuration/categories/${id.lowercase()}.category-metadata.json") + else -> return emptyMap() + } + val folder = metadataFile.parent + + val originalMetadata = files[metadataFile] + ?.let { json.decodeFromString(it().decodeToString()) } + + val changes = mutableMapOf() + + if (category?.icon?.checksum != originalCategory?.icon?.checksum) { + val path = folder / Path.of(originalMetadata?.override?.icon ?: "${id.lowercase()}.icon.png") + changes[path] = category?.icon?.let { fetchAsset(it) } + } + + val metadata = if (category != null) { + CategoryMetadata( + 1, + category.id, + category.displayNames, + category.compactNames, + category.descriptions, + category.slots, + category.tags, + category.order, + category.availableAfter, + category.availableUntil, + originalMetadata?.override ?: CategoryMetadata.Overrides(), + ) + } else { + null + } + + if (metadata != originalMetadata) { + changes[metadataFile] = metadata?.let { json.encodeToString(it).encodeToByteArray() } + } + + return changes.mapKeys { it.key.str } + } + + /** + * Computes the file changes necessary to update the type with [id] to match the given [type] data. + * Returns a map of paths relative to the repository root with associated file data (or null if the file is to be + * deleted). + * + * If no such type exists, a new one is created. + * If the passed type data is `null`, the existing type with [id] (if any) will be deleted. + * + * Note that does by itself not apply these changes. It is the responsibility of the caller to apply the returned + * values to the real file system and to call [updateFiles] if they wish these changes to be reflected in the state + * of this [GitRepoCosmeticsDatabase]. + */ + suspend fun computeChanges(id: CosmeticTypeId, type: CosmeticType?): Map { + val existingMetadataFile = typeByPath.entries.firstNotNullOfOrNull { if (it.value == id) it.key else null } + val metadataFile = when { + existingMetadataFile != null -> existingMetadataFile + type != null -> Path.of("configuration/types/${id.lowercase()}.type-metadata.json") + else -> return emptyMap() + } + + val originalMetadata = files[metadataFile] + ?.let { json.decodeFromString(it().decodeToString()) } + + val changes = mutableMapOf() + + val metadata = if (type != null) { + TypeMetadata( + 1, + type.id, + type.slot, + type.displayNames, + ) + } else { + null + } + + if (metadata != originalMetadata) { + changes[metadataFile] = metadata?.let { json.encodeToString(it).encodeToByteArray() } + } + + return changes.mapKeys { it.key.str } + } + + /** + * Computes the file changes necessary to update the bundle with [id] to match the given [bundle] data. + * Returns a map of paths relative to the repository root with associated file data (or null if the file is to be + * deleted). + * + * If no such bundle exists, a new one is created. + * If the passed bundle data is `null`, the existing bundle with [id] (if any) will be deleted. + * + * Note that does by itself not apply these changes. It is the responsibility of the caller to apply the returned + * values to the real file system and to call [updateFiles] if they wish these changes to be reflected in the state + * of this [GitRepoCosmeticsDatabase]. + */ + suspend fun computeChanges(id: CosmeticBundleId, bundle: CosmeticBundle?): Map { + val existingMetadataFile = bundleByPath.entries.firstNotNullOfOrNull { if (it.value == id) it.key else null } + val metadataFile = when { + existingMetadataFile != null -> existingMetadataFile + bundle != null -> Path.of("store-bundles/${id.lowercase()}.store-bundle-metadata.json") + else -> return emptyMap() + } + + val originalMetadata = files[metadataFile] + ?.let { json.decodeFromString(it().decodeToString()) } + + val changes = mutableMapOf() + + val metadata = if (bundle != null) { + BundleMetadata( + 1, + bundle.id, + bundle.name, + bundle.tier, + bundle.discountPercent, + bundle.skin, + bundle.cosmetics, + bundle.settings, + ) + } else { + null + } + + if (metadata != originalMetadata) { + changes[metadataFile] = metadata?.let { json.encodeToString(it).encodeToByteArray() } + } + + return changes.mapKeys { it.key.str } + } + + /** + * Computes the file changes necessary to update the featured page collection with [id] to match the given [featuredPageCollection] data. + * Returns a map of paths relative to the repository root with associated file data (or null if the file is to be + * deleted). + * + * If no such featured page collection exists, a new one is created. + * If the passed featured page collection data is `null`, the existing featured collection with [id] (if any) will be deleted. + * + * Note that does by itself not apply these changes. It is the responsibility of the caller to apply the returned + * values to the real file system and to call [updateFiles] if they wish these changes to be reflected in the state + * of this [GitRepoCosmeticsDatabase]. + */ + suspend fun computeChanges(id: FeaturedPageCollectionId, featuredPageCollection: FeaturedPageCollection?): Map { + val existingMetadataFile = featuredPageCollectionByPath.entries.firstNotNullOfOrNull { if (it.value == id) it.key else null } + val metadataFile = when { + existingMetadataFile != null -> existingMetadataFile + featuredPageCollection != null -> Path.of("featured/$id.featured-page-metadata.json") + else -> return emptyMap() + } + + val originalMetadata = files[metadataFile] + ?.let { json.decodeFromString(it().decodeToString()) } + + val changes = mutableMapOf() + + val metadata = if (featuredPageCollection != null) { + FeaturedPageCollectionMetadata( + 1, + featuredPageCollection.id, + featuredPageCollection.availability?.let { FeaturedPageCollectionMetadata.Availability(it.after, it.until) }, + featuredPageCollection.pages, + ) + } else { + null + } + + if (metadata != originalMetadata) { + changes[metadataFile] = metadata?.let { json.encodeToString(it).encodeToByteArray() } + } + + return changes.mapKeys { it.key.str } + } + + /** + * Computes the file changes necessary to update the cosmetic with [id] to match the given [cosmetic] data. + * Returns a map of paths relative to the repository root with associated file data (or null if the file is to be + * deleted). + * + * If no such cosmetic exists, a new one is created. + * If the passed cosmetic data is `null`, the existing cosmetic with [id] (if any) will be deleted. + * + * This method does not update the data of the type which is referenced by this cosmetic, only the reference itself. + * + * Note that does by itself not apply these changes. It is the responsibility of the caller to apply the returned + * values to the real file system and to call [updateFiles] if they wish these changes to be reflected in the state + * of this [GitRepoCosmeticsDatabase]. + */ + suspend fun computeChanges(id: CosmeticId, cosmetic: Cosmetic?): Map { + val originalCosmetic = cosmetics[id] + + val existingMetadataFile = cosmeticByPath.entries.firstNotNullOfOrNull { if (it.value == id) it.key else null } + val metadataFile = when { + existingMetadataFile != null -> existingMetadataFile + cosmetic != null -> { + val root = if (cosmetic.type.slot == CosmeticSlot.EMOTE) "emotes" else "cosmetics" + val type = cosmetic.type.id.lowercase().removeSuffix("_emote") + .let { if (it == "emote") "basic" else "" } + Path.of("$root/$type/${id.lowercase()}/${id.lowercase()}.cosmetic-metadata.json") + } + else -> return emptyMap() + } + + val folder = metadataFile.parent + val fileId = folder.name + + val originalMetadataUnknownVersion = files[metadataFile]?.let { jsonWithIgnoreUnknownKeys.decodeFromString(it().decodeToString()) } + + val changes = mutableMapOf() + + suspend fun writeAsset(path: String, get: CosmeticAssets.() -> EssentialAsset?) { + val original = originalCosmetic?.baseAssets?.get() + val updated = cosmetic?.baseAssets?.get() + if (original?.checksum == updated?.checksum) return + changes[folder / path] = updated?.let { fetchAsset(it) } + } + + val override = originalMetadataUnknownVersion?.override + writeAsset(override?.thumbnail ?: "$fileId.thumbnail.png") { thumbnail } + writeAsset(override?.texture ?: "$fileId.texture.png") { texture } + writeAsset(override?.geometrySteve ?: "$fileId.geometry.steve.json") { geometry.steve } + writeAsset(override?.geometryAlex ?: "$fileId.geometry.alex.json") { geometry.alex } + writeAsset(override?.animations ?: "$fileId.animations.json") { animations } + writeAsset(override?.skinMaskSteve ?: "$fileId.skin_mask.steve.png") { defaultSkinMask.steve } + writeAsset(override?.skinMaskAlex ?: "$fileId.skin_mask.alex.png") { defaultSkinMask.alex } + for (side in Side.values()) { + val sideId = side.name.lowercase() + writeAsset("$fileId.skin_mask.steve.$sideId.png") { sidedSkinMasks[side]?.steve } + writeAsset("$fileId.skin_mask.alex.$sideId.png") { sidedSkinMasks[side]?.alex } + } + + val extraPaths = mutableSetOf() + extraPaths.addAll(originalCosmetic?.files?.keys ?: emptySet()) + extraPaths.addAll(cosmetic?.files?.keys ?: emptySet()) + extraPaths.removeAll(listOf( + "thumbnail.png", + "texture.png", + "geometry.steve.json", + "geometry.alex.json", + "animations.json", + "skin_mask.steve.png", + "skin_mask.alex.png", + "settings.json", + )) + for (side in Side.values()) { + val sideId = side.name.lowercase() + extraPaths.remove("skin_mask.steve.$sideId.png") + extraPaths.remove("skin_mask.alex.$sideId.png") + } + for (path in extraPaths) { + val original = originalCosmetic?.files?.get(path) + val updated = cosmetic?.files?.get(path) + if (original?.checksum == updated?.checksum) continue + changes[folder / path] = updated?.let { fetchAsset(it) } + } + + val settingsFile = folder / (override?.settings ?: "$fileId.settings.json") + if (cosmetic?.properties != originalCosmetic?.properties) { + changes[settingsFile] = + cosmetic?.properties?.takeUnless { it.isEmpty() }?.let { json.encodeToString(it).encodeToByteArray() } + } + + if (cosmetic == null) { + if (originalMetadataUnknownVersion != null) { + // If new file is null and original is not null, pass the null along as a change + changes[metadataFile] = null + } + } else if ((originalMetadataUnknownVersion?.rev ?: 3) >= 3) { // We use the new format, unless the original is in the old format + val originalMetadataV3 = files[metadataFile]?.let { json.decodeFromString(it().decodeToString()) } + val newMetadataV3 = CosmeticMetadataV3( + 3, + cosmetic.displayNames, + cosmetic.categories, + cosmetic.tags, + cosmetic.tier, + originalMetadataV3?.organization ?: "", + originalMetadataV3?.revenueShare, + cosmetic.priceCoinsNullable, + cosmetic.availableAfter, + cosmetic.availableUntil, + cosmetic.defaultSortWeight.takeUnless { it == 20 }, + (override ?: CosmeticMetadataOverrides()).copy( + id = cosmetic.id.takeUnless { it == fileId.uppercase() }, + type = cosmetic.type.id.takeUnless { it == folder.parent.name.uppercase() } + ), + ) + if (originalMetadataV3 != newMetadataV3) { + changes[metadataFile] = newMetadataV3.let { json.encodeToString(it).encodeToByteArray() } + } + } else { + val originalMetadataV0 = files[metadataFile]?.let { json.decodeFromString(it().decodeToString()) } + val newMetadataV0 = CosmeticMetadataV0( + 0, + cosmetic.displayNames, + cosmetic.categories, + cosmetic.tags, + cosmetic.tier, + originalMetadataV0?.organization ?: "", + originalMetadataV0?.revenueShare, + cosmetic.storePackageId, + cosmetic.prices, + cosmetic.availableAfter, + cosmetic.availableUntil, + cosmetic.defaultSortWeight.takeUnless { it == 20 }, + (override ?: CosmeticMetadataOverrides()).copy( + id = cosmetic.id.takeUnless { it == fileId.uppercase() }, + type = cosmetic.type.id.takeUnless { it == folder.parent.name.uppercase() } + ), + ) + if (originalMetadataV0 != newMetadataV0) { + changes[metadataFile] = newMetadataV0.let { json.encodeToString(it).encodeToByteArray() } + } + } + + return changes.mapKeys { it.key.str } + } + + override suspend fun getCategory(id: CosmeticTypeId): CosmeticCategory? = categories[id] + override suspend fun getCategories(): List = categories.values.toList() + override suspend fun getType(id: CosmeticTypeId): CosmeticType? = types[id] + override suspend fun getTypes(): List = types.values.toList() + + override suspend fun getCosmeticBundle(id: CosmeticBundleId): CosmeticBundle? = bundles[id] + + override suspend fun getCosmeticBundles(): List = bundles.values.toList() + + override suspend fun getFeaturedPageCollection(id: FeaturedPageCollectionId): FeaturedPageCollection? = featuredPageCollections[id] + + override suspend fun getFeaturedPageCollections(): List = featuredPageCollections.values.toList() + + override suspend fun getCosmetic(id: CosmeticId): Cosmetic? { + return cosmetics[id] ?: lazyCosmetics[id]?.let { path -> + val cosmetic = tryLoadCosmetic(path) ?: return@let null + cosmetics[cosmetic.id] = cosmetic + cosmeticByPath[path] = cosmetic.id + lazyCosmetics.remove(cosmetic.id) + cosmetic + } + } + + override suspend fun getCosmetics(): List { + for ((id, path) in lazyCosmetics.toList()) { + val cosmetic = tryLoadCosmetic(path) ?: continue + cosmetics[cosmetic.id] = cosmetic + cosmeticByPath[path] = cosmetic.id + lazyCosmetics.remove(id) + } + return cosmetics.values.toList() + } + + data class Changes( + val categories: Set, + val types: Set, + val cosmetics: Set, + val bundles: Set, + val featuredPageCollections: Set, + ) { + operator fun plus(other: Changes) = + Changes( + categories + other.categories, + types + other.types, + cosmetics + other.cosmetics, + (bundles + other.bundles), // Remove () when removing flag + (featuredPageCollections + other.featuredPageCollections), // Remove () when removing flag + ) + + companion object { + val Empty = Changes( + emptySet(), + emptySet(), + emptySet(), + emptySet(), + emptySet(), + ) + } + } + + private class Observers { + val categories = mutableSetOf() + val types = mutableSetOf() + val cosmetics = mutableSetOf() + val bundles = mutableSetOf() + val featuredPageCollections = mutableSetOf() + + fun isEmpty() = categories.isEmpty() && types.isEmpty() && cosmetics.isEmpty() && bundles.isEmpty() && featuredPageCollections.isEmpty() + } + + private inner class FileAccessImpl( + private val observer: Path, + private val selector: Observers.() -> MutableSet, + ) : FileAccess { + init { + fileObservers.values.removeAll { + it.selector().remove(observer) + it.isEmpty() + } + folderObservers.values.removeAll { + it.selector().remove(observer) + it.isEmpty() + } + } + + override fun file(path: Path): (suspend () -> ByteArray)? { + fileObservers.getOrPut(path, ::Observers).selector().add(observer) + return files[path] + } + + override fun files(folder: Path): List { + folderObservers.getOrPut(folder, ::Observers).selector().add(observer) + return files.keys.filter { it.isIn(folder) } + } + } + + @Serializable + data class CategoryMetadata( + @SerialName("metadata_revision") + val rev: Int, + val id: CosmeticCategoryId, + @SerialName("display_name") + val displayNames: Map, + @SerialName("compact_name") + val compactNames: Map, + val description: Map, + val slots: Set, + val tags: Set, + val order: Int, + @SerialName("available_after") + val availableAfter: Instant?, + @SerialName("available_until") + val availableUntil: Instant?, + val override: Overrides = Overrides(), + ) { + @Serializable + data class Overrides( + val icon: String? = null, + ) + } + + @Serializable + data class TypeMetadata( + @SerialName("metadata_revision") + val rev: Int, + val id: CosmeticTypeId, + val slot: CosmeticSlot, + @SerialName("display_name") + val displayName: Map, + ) + + @Serializable + data class BundleMetadata( + @SerialName("metadata_revision") + val rev: Int, + val id: CosmeticBundleId, + val name: String, + val tier: CosmeticTier, + val discount: Float, + var skin: CosmeticBundle.Skin, + val cosmetics: Map, + val settings: Map>, + ) + + @Serializable + data class FeaturedPageCollectionMetadata( + @SerialName("metadata_revision") + val rev: Int, + val id: FeaturedPageCollectionId, + val availability: Availability? = null, + val pages: Map + ) { + + @Serializable + data class Availability(val after: Instant, val until: Instant) + + } + + @Serializable + data class CosmeticMetadataVUnknown( + @SerialName("metadata_revision") + val rev: Int, + val override: CosmeticMetadataOverrides, + ) + + // Named V0, but is actually revision 0, 1 and 2 as all of those were supposedly used before proper versioning, which now starts at 3 + @Serializable + data class CosmeticMetadataV0( + @SerialName("metadata_revision") + val rev: Int, + @SerialName("display_name") + val displayName: Map, + val categories: Map, + val tags: Set, + val tier: CosmeticTier = CosmeticTier.COMMON, + val organization: String, + @SerialName("revenue_share") + val revenueShare: Double?, + @SerialName("store_package_id") + val storePackageId: Int, + val price: Map, + @SerialName("available_after") + val availableAfter: Instant?, + @SerialName("available_until") + val availableUntil: Instant?, + @SerialName("default_sort_weight") + val defaultSortWeight: Int?, + val override: CosmeticMetadataOverrides, + ) + + @Serializable + data class CosmeticMetadataV3( + @SerialName("metadata_revision") + val rev: Int, + @SerialName("display_name") + val displayName: Map, + val categories: Map, + val tags: Set, + val tier: CosmeticTier = CosmeticTier.COMMON, + val organization: String, + @SerialName("revenue_share") + val revenueShare: Double?, + val price: Int? = null, + @SerialName("available_after") + val availableAfter: Instant?, + @SerialName("available_until") + val availableUntil: Instant?, + @SerialName("default_sort_weight") + val defaultSortWeight: Int?, + val override: CosmeticMetadataOverrides, + ) + + @Serializable + data class CosmeticMetadataOverrides( + val id: CosmeticId? = null, + val type: CosmeticTypeId? = null, + + @SerialName("asset.geometry.steve") + val geometrySteve: String? = null, + @SerialName("asset.geometry.alex") + val geometryAlex: String? = null, + @SerialName("asset.animations") + val animations: String? = null, + @SerialName("asset.skin_mask.steve") + val skinMaskSteve: String? = null, + @SerialName("asset.skin_mask.alex") + val skinMaskAlex: String? = null, + @SerialName("asset.settings") + val settings: String? = null, + @SerialName("asset.texture") + val texture: String? = null, + @SerialName("asset.thumbnail") + val thumbnail: String? = null, + ) +} + +typealias AssetBuilder = suspend (path: String, read: suspend () -> ByteArray) -> EssentialAsset + +private val dataUrlBase64Prefix = "data:;base64," + +private val json = Json { + prettyPrint = true + serializersModule = CosmeticProperty.TheSerializer.module + CosmeticSetting.TheSerializer.module +} + +private val jsonWithIgnoreUnknownKeys = Json { + prettyPrint = true + serializersModule = CosmeticProperty.TheSerializer.module + CosmeticSetting.TheSerializer.module + ignoreUnknownKeys = true +} + +@JvmInline +private value class Path private constructor(val str: String) { + val name: String + get() = str.substringAfterLast("/") + val parent: Path + get() = this / ".." + + fun isEmpty() = str.isEmpty() + fun isIn(other: Path) = str.startsWith("$other/") + fun relativeTo(other: Path) = Path(str.removePrefix(other.str).removePrefix("/")) + + operator fun div(other: String) = of("$this/$other") + operator fun div(other: Path) = of("$this/$other") + + override fun toString(): String { + return str + } + + companion object { + fun of(str: String): Path { + val parts = str + .replace('\\', '/') + .removePrefix("~/") + .substringAfter("/~/") + .split("/") + .filterNot { it == "" || it == "." } + .toMutableList() + var i = 0 + while (i <= parts.lastIndex) { + if (i > 0 && parts[i] == ".." && parts[i - 1] != "..") { + parts.removeAt(i) + parts.removeAt(i - 1) + i-- + } else { + i++ + } + } + return Path(parts.joinToString("/")) + } + } +} + +private interface FileAccess { + fun file(path: Path): (suspend () -> ByteArray)? + fun files(folder: Path): List +} + +private suspend fun FileAccess.loadCategory(metadataFile: Path, assetBuilder: AssetBuilder): CosmeticCategory { + val metadataJson = file(metadataFile) ?: throw NoSuchElementException(metadataFile.toString()) + val metadata = json.decodeFromString(metadataJson().decodeToString()) + val id = metadata.id + val fileId = id.lowercase() + val folder = metadataFile.parent + + suspend fun getAsset(path: String): EssentialAsset? { + val filePath = folder / path + val file = file(filePath) ?: return null + return assetBuilder(filePath.str, file) + } + + suspend fun getAssetOrThrow(path: String): EssentialAsset { + return getAsset(path) ?: throw NoSuchElementException((folder / path).toString()) + } + + return CosmeticCategory( + id, + getAssetOrThrow(metadata.override.icon ?: "$fileId.icon.png"), + metadata.displayNames, + metadata.compactNames, + metadata.description, + metadata.slots, + metadata.tags, + metadata.order, + metadata.availableAfter, + metadata.availableUntil, + ) +} + +private suspend fun FileAccess.loadType(metadataFile: Path): CosmeticType { + val metadataJson = file(metadataFile) ?: throw NoSuchElementException(metadataFile.toString()) + val metadata = json.decodeFromString(metadataJson().decodeToString()) + return CosmeticType( + metadata.id, + metadata.slot, + metadata.displayName, + emptyMap(), + ) +} + +private suspend fun FileAccess.loadBundle(metadataFile: Path): CosmeticBundle { + val metadataJson = file(metadataFile) ?: throw NoSuchElementException(metadataFile.toString()) + val metadata = json.decodeFromString(metadataJson().decodeToString()) + return CosmeticBundle( + metadata.id, + metadata.name, + metadata.tier, + metadata.discount, + metadata.skin, + metadata.cosmetics, + metadata.settings + ) +} + +private suspend fun FileAccess.loadFeaturedPageCollection(metadataFile: Path): FeaturedPageCollection { + val metadataJson = file(metadataFile) ?: throw NoSuchElementException(metadataFile.toString()) + val metadata = json.decodeFromString(metadataJson().decodeToString()) + return FeaturedPageCollection( + metadata.id, + metadata.availability?.let { FeaturedPageCollection.Availability(it.after, it.until) }, + metadata.pages + ) +} + +private suspend fun FileAccess.loadCosmetic(metadataFile: Path, assetBuilder: AssetBuilder, getType: (id: CosmeticTypeId) -> CosmeticType?): Cosmetic { + val metadataJson = file(metadataFile) ?: throw NoSuchElementException(metadataFile.toString()) + val metadataUnknownVersion = jsonWithIgnoreUnknownKeys.decodeFromString(metadataJson().decodeToString()) + val metadata: GitRepoCosmeticsDatabase.CosmeticMetadataV0 + + if (metadataUnknownVersion.rev >= 3) { + // Convert v3 to v0 for simplicity + val metadataV3 = json.decodeFromString(metadataJson().decodeToString()) + metadata = GitRepoCosmeticsDatabase.CosmeticMetadataV0( + metadataV3.rev, + metadataV3.displayName, + metadataV3.categories, + metadataV3.tags, + metadataV3.tier, + metadataV3.organization, + metadataV3.revenueShare, + 0, + if (metadataV3.price != null) mapOf("coins" to metadataV3.price.toDouble()) else mapOf(), + metadataV3.availableAfter, + metadataV3.availableUntil, + metadataV3.defaultSortWeight, + metadataV3.override, + ) + } else { + metadata = json.decodeFromString(metadataJson().decodeToString()) + } + + val folder = metadataFile.parent + val fileId = folder.name + val assets = mutableMapOf() + val assetFiles = files(folder).mapTo(mutableSetOf()) { it.relativeTo(folder) } + assetFiles.remove(Path.of(metadataFile.name)) + + suspend fun assetOverride(override: String?, name: String) { + val relFilePath = Path.of(override ?: "$fileId.$name") + val filePath = folder / relFilePath + val file = file(filePath) ?: return + assetFiles.remove(relFilePath) + assets[name] = assetBuilder(filePath.str, file) + } + + val override = metadata.override + + assetOverride(override.thumbnail, "thumbnail.png") + assetOverride(override.texture, "texture.png") + assetOverride(override.geometrySteve, "geometry.steve.json") + assetOverride(override.geometryAlex, "geometry.alex.json") + assetOverride(override.animations, "animations.json") + assetOverride(override.skinMaskSteve, "skin_mask.steve.png") + assetOverride(override.skinMaskAlex, "skin_mask.alex.png") + for (side in Side.values()) { + val sideId = side.name.lowercase() + assetOverride(null, "skin_mask.steve.$sideId.png") + assetOverride(null, "skin_mask.alex.$sideId.png") + } + + val settingsFile = (override.settings ?: "$fileId.settings.json") + assetFiles.remove(Path.of(settingsFile)) + val settings = file(folder / settingsFile) + ?.let { CosmeticProperty.fromJsonArray(it().decodeToString()) } + + for (path in assetFiles) { + val filePath = folder / path + val file = file(filePath)!! + assets[path.str] = assetBuilder(filePath.str, file) + } + + val folderTypeId = folder.parent.name.uppercase() + val type = override.type?.let(getType) + // Special cases for emotes to avoid repetition because the parent folder is already called `emotes` + ?: getType(if (folderTypeId == "BASIC") "EMOTE" else folderTypeId + "_EMOTE") + // Regular folder-derived type + ?: getType(folderTypeId) + // Unknown type + ?: CosmeticType( + folderTypeId, + CosmeticSlot.of(folderTypeId), + mapOf("en_us" to folderTypeId), + emptyMap(), + ) + return Cosmetic( + override.id ?: fileId.uppercase(), // repo files use lowercase ids, actual ids should be uppercase + type, + metadata.tier, + mapOf("en_us" to fileId) + metadata.displayName, + assets, + settings ?: emptyList(), + metadata.storePackageId, + metadata.price, + metadata.tags, + metadata.availableAfter ?: now(), + metadata.availableAfter, + metadata.availableUntil, + emptyMap(), + metadata.categories, + metadata.defaultSortWeight ?: 20, + ) +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItem.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItem.kt new file mode 100644 index 0000000..e65a669 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItem.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.featured + +import gg.essential.cosmetics.CosmeticBundleId +import gg.essential.cosmetics.CosmeticId +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.JsonClassDiscriminator + +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("type") +@Serializable +sealed class FeaturedItem { + + abstract val width: Int + abstract val height: Int + abstract val type: FeaturedItemType + + @SerialName("COSMETIC") + @Serializable + data class Cosmetic( + val cosmetic: CosmeticId, + val settings: List, + override val width: Int = 1, + override val height: Int = width, + ) : FeaturedItem() { + + @Transient + override val type: FeaturedItemType = FeaturedItemType.COSMETIC + + } + + @SerialName("BUNDLE") + @Serializable + data class Bundle( + val bundle: CosmeticBundleId, + override val width: Int = 2, + override val height: Int = width, + ) : FeaturedItem() { + + @Transient + override val type: FeaturedItemType = FeaturedItemType.BUNDLE + + } + + @SerialName("EMPTY") + @Serializable + data class Empty( + override val width: Int, + override val height: Int = width, + ) : FeaturedItem() { + + @Transient + override val type: FeaturedItemType = FeaturedItemType.EMPTY + + } + +} + + diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItemType.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItemType.kt new file mode 100644 index 0000000..932d3d4 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedItemType.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.featured + +import kotlinx.serialization.Serializable + +@Serializable +enum class FeaturedItemType { + + COSMETIC, + BUNDLE, + EMPTY, + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPage.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPage.kt new file mode 100644 index 0000000..2a56057 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPage.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.featured + +import kotlinx.serialization.Serializable + +@Serializable +data class FeaturedPage( + val rows: List>, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPageCollection.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPageCollection.kt new file mode 100644 index 0000000..a651479 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/featured/FeaturedPageCollection.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +@file:UseSerializers(InstantAsIso8601Serializer::class) + +package gg.essential.mod.cosmetics.featured + +import gg.essential.cosmetics.FeaturedPageCollectionId +import gg.essential.cosmetics.FeaturedPageWidth +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import gg.essential.model.util.Instant +import gg.essential.model.util.InstantAsIso8601Serializer +import gg.essential.model.util.now +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +@Serializable +data class FeaturedPageCollection( + val id: FeaturedPageCollectionId, + val availability: Availability? = null, + val pages: Map +) { + + /** + * This is used by the featured category page and the Wardrobe width modifier to get the most appropriate layout for the available columns. + * + * It gets the layout entry whose width is closest to the desired one, prioritizing smaller ones, otherwise picking at most one bigger + */ + fun getClosestLayoutOrNull(desiredColumns: Int): Map.Entry? = + pages.entries + .filter { it.key <= desiredColumns + 1 } + .minByOrNull { + when { + it.key == desiredColumns -> 0 + it.key < desiredColumns -> desiredColumns - it.key + else -> it.key - desiredColumns + 100 // Make sure pages with smaller width get picked first + } + } + + fun isAvailable(): Boolean = isAvailableAt(now()) + + fun isAvailableAt(time: Instant): Boolean = availability == null || (availability.after < time && time < availability.until) + + @Serializable + data class Availability(val after: Instant, val until: Instant) + + companion object { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + serializersModule = CosmeticSetting.TheSerializer.module + } + @JvmStatic + fun parse(str: String): FeaturedPageCollection = json.decodeFromString(str) + } + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/preview/PerspectiveCamera.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/preview/PerspectiveCamera.kt new file mode 100644 index 0000000..4d823d8 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/preview/PerspectiveCamera.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.preview + +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import dev.folomeev.kotgl.matrix.vectors.mutables.minus +import dev.folomeev.kotgl.matrix.vectors.mutables.times +import dev.folomeev.kotgl.matrix.vectors.vec3 +import dev.folomeev.kotgl.matrix.vectors.vecUnitY +import dev.folomeev.kotgl.matrix.vectors.vecZero +import gg.essential.mod.cosmetics.CosmeticSlot +import gg.essential.model.util.Quaternion +import gg.essential.model.util.UMatrixStack + +data class PerspectiveCamera(val camera: Vec3, val target: Vec3, val fov: Float) { + val rotation: Quaternion + get() = Quaternion.fromLookAt(target.minus(camera), vecUnitY()) + + fun createModelViewMatrix(): UMatrixStack { + val stack = UMatrixStack() + stack.rotate(rotation.invert()) + stack.translate(vecZero().minus(camera)) + return stack + } + + companion object { + fun forCosmeticSlot(slot: CosmeticSlot): PerspectiveCamera { + + @Suppress("FunctionName") + fun Vector3(x: Number, y: Number, z: Number): Vec3 = + vec3(x.toFloat(), y.toFloat(), z.toFloat()) + + infix fun Vec3.to(target: Vec3): PerspectiveCamera = + PerspectiveCamera(this.times(1 / 16f), target.times(1 / 16f), 22f) + + // The player is standing on 0/0/0 looking towards negative Z. + return when (slot) { + CosmeticSlot.CAPE -> Vector3(-30.3, 38.5, 46.6) to Vector3(0.0, 14.9, 0.0) + CosmeticSlot.BACK -> Vector3(-50.3, 48.5, 60.6) to Vector3(0.0, 16.9, 0.0) + CosmeticSlot.HAT -> Vector3(34.6, 50.5, -40) to Vector3(0, 30, 0) + CosmeticSlot.WINGS -> Vector3(0, 20, 138) to Vector3(0, 20, 0) + CosmeticSlot.FACE, CosmeticSlot.HEAD -> Vector3(29.7, 44, -34.3) to Vector3(0, 26, 0) + CosmeticSlot.SHOULDERS -> Vector3(32.3, 37, -36.8) to Vector3(2.6, 24, -2.5) + CosmeticSlot.ARMS -> Vector3(32.3, 34.9, -36.8) to Vector3(2.6, 16.9, -2.5) + CosmeticSlot.PANTS -> Vector3(34, 29.1, -38.4) to Vector3(4.3, 11.1, -4.1) + CosmeticSlot.SHOES -> Vector3(30.5, 12.4, -29.4) to Vector3(4.2, 4.8, -3.8) + CosmeticSlot.SUITS -> Vector3(73.3, 20.7, -81.3) to Vector3(3.7, 19.2, -2.8) + CosmeticSlot.EFFECT -> Vector3(73.3, 20.7, -81.3) to Vector3(3.7, 18.2, -2.8) + CosmeticSlot.EARS -> Vector3(29.7, 44, -34.3) to Vector3(0, 26, 0) // FIXME + CosmeticSlot.EMOTE -> Vector3(71.8, 17.2, -81.3) to Vector3(2.2, 15.7, -2.8) + CosmeticSlot.ICON -> Vector3(34.6, 50.5, -40) to Vector3(0, 30, 0) + CosmeticSlot.TOP -> Vector3(34.7, 36.2, -39.5) to Vector3(2.6, 17.0, -2.5) + CosmeticSlot.ACCESSORY -> Vector3(34.7, 36.2, -39.5) to Vector3(2.6, 17.0, -2.5) + CosmeticSlot.FULL_BODY -> Vector3(73.3, 20.7, -81.3) to Vector3(3.7, 18.2, -2.8) + + // These have a camera config but no cosmetics yet + // CosmeticSlot.RIDEABLE -> Vector3(73.3, 20.7, -81.3) to Vector3(3.7, 19.2, -2.8), + + // Fall back for other/unknown slots (currently same as FULL_BODY, leaving separate if it changes) + else -> Vector3(73.3, 20.7, -81.3) to Vector3(3.7, 18.2, -2.8) + } + } + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticProperty.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticProperty.kt new file mode 100644 index 0000000..e195991 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticProperty.kt @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.settings + +import gg.essential.model.Side +import gg.essential.model.util.Color +import gg.essential.model.util.ColorAsRgbSerializer +import kotlinx.serialization.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("type") +@Serializable(with = CosmeticProperty.TheSerializer::class) +sealed class CosmeticProperty { + + abstract val id: String? + abstract val enabled: Boolean + abstract val type: CosmeticPropertyType? + + @SerialName("__unknown__") + @Serializable + data class Unknown( + override val id: String?, + override val enabled: Boolean, + @SerialName("__type") // see CosmeticProperty.TheSerializer + val typeStr: String, + val data: JsonObject, + ) : CosmeticProperty() { + @Transient + override val type: CosmeticPropertyType? = null + } + + @SerialName("ARMOR_HANDLING") + @Serializable + data class ArmorHandling( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.ARMOR_HANDLING + + @Serializable + data class Data( + val head: Boolean = false, + val arms: Boolean = false, + val body: Boolean = false, + val legs: Boolean = false, + ) + } + + @SerialName("COSMETIC_BONE_HIDING") + @Serializable + data class CosmeticBoneHiding( + override val id: String, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.COSMETIC_BONE_HIDING + + @Serializable + data class Data( + val head: Boolean = false, + val arms: Boolean = false, + val body: Boolean = false, + val legs: Boolean = false, + ) + } + + @SerialName("POSITION_RANGE") + @Serializable + data class PositionRange( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.POSITION_RANGE + + @Serializable + data class Data( + @SerialName("x_min") val xMin: Float? = null, + @SerialName("x_max") val xMax: Float? = null, + @SerialName("y_min") val yMin: Float? = null, + @SerialName("y_max") val yMax: Float? = null, + @SerialName("z_min") val zMin: Float? = null, + @SerialName("z_max") val zMax: Float? = null, + ) + } + + @SerialName("EXTERNAL_HIDDEN_BONE") + @Serializable + data class ExternalHiddenBone( + override val id: String, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.EXTERNAL_HIDDEN_BONE + + @Serializable(with = ExternalHiddenBoneDataSerializer::class) + data class Data( + val hiddenBones: Set, + ) + + object ExternalHiddenBoneDataSerializer : KSerializer { + + private val inner = JsonObject.serializer() + override val descriptor = inner.descriptor + + override fun deserialize(decoder: Decoder): Data { + val jsonObject: JsonObject = decoder.decodeSerializableValue(inner) + + val hiddenBones = mutableSetOf() + for ((key, value) in jsonObject) { + if (value is JsonPrimitive && value.boolean) { + hiddenBones.add(key) + } + } + + return Data(hiddenBones) + } + + override fun serialize(encoder: Encoder, value: Data) { + val jsonObject = JsonObject(value.hiddenBones.associateWith { JsonPrimitive(true) }) + encoder.encodeSerializableValue(inner, jsonObject) + } + } + } + + @SerialName("INTERRUPTS_EMOTE") + @Serializable + data class InterruptsEmote( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.INTERRUPTS_EMOTE + + @Serializable + data class Data( + /** If true, a change in player position will interrupt the emote. */ + @SerialName("MOVEMENT") val movement: Boolean = false, + /** The [movement] condition will be ignored during the first [movementGraceTime] milliseconds. */ + @SerialName("MOVEMENT_ACTIVE_AFTER") val movementGraceTime: Double = 0.0, + /** If true, attacking another entity will interrupt the emote. */ + @SerialName("ATTACK") val attack: Boolean = true, + /** If true, being damaged will interrupt the emote. */ + @SerialName("DAMAGED") val damaged: Boolean = false, + /** If true, the emote will be interrupt when the player is swinging their arm. */ + @SerialName("ARM_SWING") val armSwing: Boolean = true, + ) + } + + @SerialName("REQUIRES_UNLOCK_ACTION") + @Serializable + data class RequiresUnlockAction( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.REQUIRES_UNLOCK_ACTION + + @Serializable + @JsonClassDiscriminator("ACTION_TYPE") + sealed class Data { + + abstract val actionDescription: String + + @Serializable + @SerialName("OPEN_LINK") + data class OpenLink( + @SerialName("ACTION_DESCRIPTION") + override val actionDescription: String, + @SerialName("LINK_ADDRESS") val linkAddress: String, + @SerialName("LINK_SHORT") val linkShort: String, + ): Data() + + @Serializable + @SerialName("JOIN_SPS") + data class JoinSps( + @SerialName("ACTION_DESCRIPTION") + override val actionDescription: String, + @SerialName("REQUIRED_VERSION") val requiredVersion: String?, + ): Data() + + @Serializable + @SerialName("JOIN_SERVER") + data class JoinServer( + @SerialName("ACTION_DESCRIPTION") + override val actionDescription: String, + @SerialName("SERVER_ADDRESS") val serverAddress: String, + ): Data() + } + } + + @SerialName("PREVIEW_RESET_TIME") + @Serializable + data class PreviewResetTime( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.PREVIEW_RESET_TIME + + @Serializable + data class Data( + val time: Double, + ) + } + + @SerialName("LOCALIZATION") + @Serializable + data class Localization( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.LOCALIZATION + + @Serializable + data class Data( + val en_US: String, + ) + } + + @SerialName("TRANSITION_DELAY") + @Serializable + data class TransitionDelay( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.TRANSITION_DELAY + + @Serializable + data class Data( + val time: Long, + ) + } + + @SerialName("VARIANTS") + @Serializable + data class Variants( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.VARIANTS + + @Serializable + data class Data( + val variants: List, + ) + + @Serializable + data class Variant( + val name: String, + @Serializable(ColorAsRgbSerializer::class) + val color: Color, + ) + } + + @SerialName("DEFAULT_SIDE") + @Serializable + data class DefaultSide( + override val id: String?, + override val enabled: Boolean, + val data: Data + ) : CosmeticProperty() { + + @Transient + override val type: CosmeticPropertyType = CosmeticPropertyType.DEFAULT_SIDE + + @Serializable + data class Data( + val side: Side, + ) + } + + object TheSerializer : FallbackPolymorphicSerializer(CosmeticProperty::class, "type", "__type", "__unknown__") { + override val module = SerializersModule { + polymorphic(CosmeticProperty::class) { + subclass(Unknown::class, Unknown.serializer()) + subclass(ArmorHandling::class, ArmorHandling.serializer()) + subclass(CosmeticBoneHiding::class, CosmeticBoneHiding.serializer()) + subclass(PositionRange::class, PositionRange.serializer()) + subclass(ExternalHiddenBone::class, ExternalHiddenBone.serializer()) + subclass(InterruptsEmote::class, InterruptsEmote.serializer()) + subclass(RequiresUnlockAction::class, RequiresUnlockAction.serializer()) + subclass(PreviewResetTime::class, PreviewResetTime.serializer()) + subclass(Localization::class, Localization.serializer()) + subclass(TransitionDelay::class, TransitionDelay.serializer()) + subclass(Variants::class, Variants.serializer()) + subclass(DefaultSide::class, DefaultSide.serializer()) + } + } + } + + companion object { + + val json by lazy { // lazy to prevent initialization cycle in serializers + Json { + ignoreUnknownKeys = true + serializersModule = TheSerializer.module + } + } + + fun fromJsonArray(json: String): List { + var mutableJson = json + // FIXME: Temporary workaround for typo + mutableJson = mutableJson.replace("COSEMTIC", "COSMETIC") + return this.json.decodeFromString(mutableJson) + } + + fun fromJson(json: String): CosmeticProperty { + var mutableJson = json + // FIXME: Temporary workaround for typo + mutableJson = mutableJson.replace("COSEMTIC", "COSMETIC") + return this.json.decodeFromString(mutableJson) + } + + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticPropertyType.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticPropertyType.kt new file mode 100644 index 0000000..2589d36 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticPropertyType.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.settings + +/** Types and names of properties that can be applied to cosmetics to affect + * different aspects of them **/ +enum class CosmeticPropertyType( + val displayName: String, + val singleton: Boolean, +) { + ARMOR_HANDLING("Armor Handling", true), + POSITION_RANGE("Player Position Adjustment", true), + INTERRUPTS_EMOTE("Interrupts Emote", true), + REQUIRES_UNLOCK_ACTION("Requires Unlock Action", true), + PREVIEW_RESET_TIME("Preview Reset Time", true), + LOCALIZATION("Partner Name Localization", true), + COSMETIC_BONE_HIDING("Cosmetic Bone Hiding", false), + EXTERNAL_HIDDEN_BONE("External Hidden Bones", false), + TRANSITION_DELAY("Transition Delay", true), + VARIANTS("Variants", true), + DEFAULT_SIDE("Default Side", true), +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSetting.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSetting.kt new file mode 100644 index 0000000..b7908f7 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSetting.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.settings + +import kotlinx.serialization.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("type") +@Serializable(with = CosmeticSetting.TheSerializer::class) +sealed class CosmeticSetting { + + + abstract val id: String? + @Deprecated("unused") + abstract val enabled: Boolean + abstract val type: CosmeticSettingType? + + @SerialName("__unknown__") + @Serializable + data class Unknown( + override val id: String?, + @Deprecated("unused") + override val enabled: Boolean, + @SerialName("__type") // see CosmeticSetting.TheSerializer + val typeStr: String, + val data: JsonObject, + ) : CosmeticSetting() { + @Transient + override val type: CosmeticSettingType? = null + } + + + @SerialName("PLAYER_POSITION_ADJUSTMENT") + @Serializable + data class PlayerPositionAdjustment( + override val id: String?, + @Deprecated("unused") + override val enabled: Boolean, + val data: Data + ) : CosmeticSetting() { + + @Transient + override val type: CosmeticSettingType = CosmeticSettingType.PLAYER_POSITION_ADJUSTMENT + + @Serializable + data class Data( + val x: Float = 0f, + val y: Float = 0f, + val z: Float = 0f, + ) + } + + @SerialName("SIDE") + @Serializable + data class Side( + override val id: String?, + @Deprecated("unused") + override val enabled: Boolean, + val data: Data + ) : CosmeticSetting() { + + @Transient + override val type: CosmeticSettingType = CosmeticSettingType.SIDE + + // Side defaults to LEFT because of old settings failing to parse otherwise. + // You should probably always provide your own value to this constructor. See comments about default sides in Side. + @Serializable + data class Data( + @SerialName("SIDE") + @Serializable(with = gg.essential.model.Side.UpperCase::class) + val side: gg.essential.model.Side = gg.essential.model.Side.LEFT, + ) + } + + @SerialName("VARIANT") + @Serializable + data class Variant( + override val id: String?, + @Deprecated("unused") + override val enabled: Boolean, + val data: Data + ) : CosmeticSetting() { + + @Transient + override val type: CosmeticSettingType = CosmeticSettingType.VARIANT + + @Serializable + data class Data( + val variant: String, + ) + } + + object TheSerializer : FallbackPolymorphicSerializer(CosmeticSetting::class, "type", "__type", "__unknown__") { + override val module = SerializersModule { + polymorphic(CosmeticSetting::class) { + subclass(Unknown::class, Unknown.serializer()) + subclass(PlayerPositionAdjustment::class, PlayerPositionAdjustment.serializer()) + subclass(Side::class, Side.serializer()) + subclass(Variant::class, Variant.serializer()) + } + } + } + + companion object { + val json by lazy { // lazy to prevent initialization cycle in serializers + Json { + ignoreUnknownKeys = true + serializersModule = TheSerializer.module + } + } + + fun fromJsonArray(json: String): List { + return this.json.decodeFromString(json) + } + + fun fromJson(json: String): CosmeticSetting { + return this.json.decodeFromString(json) + } + } + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettingType.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettingType.kt new file mode 100644 index 0000000..e039ab6 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettingType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.settings + + +/** Settings that apply to a users configuration of a cosmetic **/ +enum class CosmeticSettingType( + val displayName: String, + val singleton: Boolean, +) { + + PLAYER_POSITION_ADJUSTMENT("Player Position Adjustment", true), + SIDE("Side", true), + VARIANT("Variant", true), +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettings.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettings.kt new file mode 100644 index 0000000..ce6d8b8 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/CosmeticSettings.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.settings + +import gg.essential.model.Side + +typealias CosmeticSettings = List + +inline fun CosmeticSettings.setting(): T? = firstNotNullOfOrNull { it as? T } +inline fun CosmeticSettings.settings(): List = filterIsInstance() + +val CosmeticSettings.side: Side? + get() = setting()?.data?.side + +val CosmeticSettings.variant: String? + get() = setting()?.data?.variant diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/FallbackPolymorphicSerializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/FallbackPolymorphicSerializer.kt new file mode 100644 index 0000000..e032bd5 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/FallbackPolymorphicSerializer.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.settings + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.modules.SerializersModule +import kotlin.reflect.KClass + +/** Serializer which uses a special Unknown subclass as a fallback when the polymorphic serializer does not have an exact match. */ +// While we could use the generated sealed class serializer directly with `polymorphicDefaultDeserializer` to +// decode Unknown properties, it'll complain during encoding that Unknown has an overlapping `type` field that +// conflicts with the discriminator field. +// We cannot use a wrapper to decide between the sealed class serializer and the Unknown serializer because +// `@Serializer` is broken: https://github.com/Kotlin/kotlinx.serialization/issues/970 +// So we'll just use a different intermediate name for the Unknown `type` and transform the json afterwards: +@OptIn(ExperimentalSerializationApi::class) +abstract class FallbackPolymorphicSerializer( + private val baseClass: KClass, + private val discriminatorField: String, + private val unknownDiscriminatorField: String, + private val unknownSerialName: String, +) : JsonTransformingSerializer(PolymorphicSerializer(baseClass)) { + // And because we can't use the auto-generated polymorphic serializer at the same time, we need to create one + // manually: + abstract val module: SerializersModule + + override fun transformSerialize(element: JsonElement): JsonElement { + return if (unknownDiscriminatorField in element.jsonObject) { + JsonObject(element.jsonObject.toMutableMap().apply { + put(discriminatorField, remove(unknownDiscriminatorField)!!) + }) + } else element + } + + override fun transformDeserialize(element: JsonElement): JsonElement { + val type = element.jsonObject.getValue(discriminatorField).jsonPrimitive.content + return if (module.getPolymorphic(baseClass, type.uppercase()) != null) { + // FIXME: Temporary workaround for the fact that some types are not capitalized in assets/database + JsonObject(element.jsonObject + (discriminatorField to JsonPrimitive(type.uppercase()))) + } else if (module.getPolymorphic(baseClass, type) == null) { + JsonObject(element.jsonObject.toMutableMap().apply { + put(unknownDiscriminatorField, JsonPrimitive(type)) + put(discriminatorField, JsonPrimitive(unknownSerialName)) + }) + } else element + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/UntypedCosmeticSetting.kt b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/UntypedCosmeticSetting.kt new file mode 100644 index 0000000..38e4fb8 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/settings/UntypedCosmeticSetting.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.mod.cosmetics.settings + +import gg.essential.model.util.AnySerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UntypedCosmeticSetting( + val id: String?, + val type: String, + @SerialName("enabled") + val isEnabled: Boolean, + val data: Map, +) { + @Suppress("UNCHECKED_CAST") + fun getData(key: String): T? { + return data[key] as T? + } + + fun hasData(key: String): Boolean { + return data.containsKey(key) + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/Animation.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/Animation.kt new file mode 100644 index 0000000..506d702 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/Animation.kt @@ -0,0 +1,410 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import dev.folomeev.kotgl.matrix.matrices.mutables.timesSelf +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import dev.folomeev.kotgl.matrix.vectors.mutables.minus +import dev.folomeev.kotgl.matrix.vectors.mutables.timesSelf +import dev.folomeev.kotgl.matrix.vectors.vec3 +import dev.folomeev.kotgl.matrix.vectors.vec4 +import dev.folomeev.kotgl.matrix.vectors.vecZero +import gg.essential.model.file.AnimationFile +import gg.essential.model.file.KeyframeSerializer +import gg.essential.model.file.KeyframesSerializer +import gg.essential.model.molang.* +import gg.essential.model.util.Quaternion +import gg.essential.model.util.TreeMap +import gg.essential.model.util.UMatrixStack +import gg.essential.model.util.times +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.math.PI + +class ModelAnimationState( + val entity: MolangQueryEntity, + val parentLocator: ParticleSystem.Locator, +) { + val active: MutableList = mutableListOf() + val pendingEvents: MutableList = mutableListOf() + + private val locators = mutableMapOf() + private var lastLocatorUpdateTime = 0f // velocity will not be computed on the first update anyway + + fun startAnimation(animation: Animation) { + if (active.any { it.animation == animation }) { + return + } + active.add(AnimationState(animation)) + + animation.effects.values.forEach { list -> + list.forEach { event -> + val name = (event as? Animation.ParticleEvent)?.locator?.boxName + if (name != null && name !in locators) { + locators[name] = BoneLocator(vec3(), Quaternion.Identity, vec3()) + } + } + } + } + + private fun findAndResetBones(bone: Bone, map: MutableMap) { + map[bone.boxName] = bone + bone.resetAnimationOffsets(false) + if (bone.childModels != null) { + for (childModel in bone.childModels) { + findAndResetBones(childModel, map) + } + } + } + + fun apply(model: Bone, affectPose: Boolean) { + val bones = mutableMapOf() + findAndResetBones(model, bones) + + + for (state in active) { + for ((boneName, channels) in state.animation.bones) { + val bone = bones[boneName] ?: continue + if (bone.affectsPose != affectPose) continue + channels.relativeTo.rotation?.let { _ -> + bone.gimbal = true + } + channels.position?.eval(state.context)?.let { (x, y, z) -> + bone.animOffsetX += x + bone.animOffsetY += y + bone.animOffsetZ += z + } + channels.rotation?.eval(state.context)?.let { (x, y, z) -> + bone.animRotX = (x / 180 * PI).toFloat() + bone.animRotY = (y / 180 * PI).toFloat() + bone.animRotZ = (z / 180 * PI).toFloat() + } + channels.scale?.eval(state.context)?.let { (x, y, z) -> + bone.animScaleX *= x + bone.animScaleY *= y + bone.animScaleZ *= z + } + } + } + } + + /** Whether there are any locators that still need to be updated via [updateLocators] for this frame. */ + fun locatorsNeedUpdating() = + locators.isNotEmpty() && lastLocatorUpdateTime < entity.lifeTime + + /** Updates the position/rotation/velocity of all locators based on the current transform of bones in [rootBone]. */ + fun updateLocators(rootBone: Bone, scale: Float) { + if (locators.isEmpty()) { + return // nothing to do + } + + val now = entity.lifeTime + val dt = now - lastLocatorUpdateTime + if (dt <= 0) { + return + } + lastLocatorUpdateTime = now + + // TODO maybe optimize traversal, don't need to compute subtree with only dead ends (same for retrievePose) + fun Bone.visit(matrixStack: UMatrixStack, parentHasScaling: Boolean) { + val locator = locators[boxName] + + if (locator == null && childModels.isEmpty()) { + return + } + + val hasScaling = parentHasScaling || animScaleX != 1f || animScaleY != 1f || animScaleZ != 1f + + matrixStack.push() + matrixStack.translate(pivotX + animOffsetX, pivotY - animOffsetY, pivotZ + animOffsetZ) + if (gimbal) { + matrixStack.rotate(parentRotation.conjugate()) + } + matrixStack.rotate(rotateAngleZ + animRotZ, 0.0f, 0.0f, 1.0f, false) + matrixStack.rotate(rotateAngleY + animRotY, 0.0f, 1.0f, 0.0f, false) + matrixStack.rotate(rotateAngleX + animRotX, 1.0f, 0.0f, 0.0f, false) + + if (locator != null) { + val matrix = matrixStack.peek().model + val lastPosition = locator.position + val nextPosition = with(vec4(0f, 0f, 0f, 1f).times(matrix)) { vec3(x, y, z) } + locator.position = nextPosition + + // LookAt is towards -1 because as per OpenGL convention the camera is looking towards negative Z. + val lookAt = with(vec4(0f, 0f, -1f, 1f).times(matrix)) { vec3(x, y, z) }.minus(nextPosition) + // Up is towards -1 because Mojang renders models upside down, and our cosmetics have been built around that + val up = with(vec4(0f, -1f, 0f, 1f).times(matrix)) { vec3(x, y, z) }.minus(nextPosition) + locator.rotation = Quaternion.fromLookAt(lookAt, up) + + // Only update if we have a valid previous value (we cannot compute velocity from just the first frame) + // and if some time has passed since the last update (otherwise velocity is ill-defined) + if (lastPosition != vecZero() && dt > 0f) { + locator.velocity = nextPosition.minus(lastPosition).timesSelf(1 / dt) + } + } + + extra?.let { + matrixStack.peek().model.timesSelf(it) + } + matrixStack.scale(animScaleX, animScaleY, animScaleZ) + matrixStack.translate(-pivotX - userOffsetX, -pivotY - userOffsetY, -pivotZ - userOffsetZ) + + for (childModel in childModels) { + childModel.visit(matrixStack, hasScaling) + } + + matrixStack.pop() + } + + val matrixStack = UMatrixStack() + matrixStack.translate(parentLocator.position) + matrixStack.rotate(parentLocator.rotation) + matrixStack.scale(scale) + matrixStack.scale(-1f, -1f, 1f) // see RenderLivingBase.prepareScale + matrixStack.scale(0.9375f) // see RenderPlayer.preRenderCallback + rootBone.visit(matrixStack, false) + } + + /** Emits effect keyframes into [pendingEvents]. */ + fun updateEffects() { + for (state in active) { + while (true) { + val (nextTime, effects) = state.animation.effects.higherEntry(state.lastEffectTime) ?: break + if (nextTime > state.animTime) { + break + } + state.lastEffectTime = nextTime + effects.forEach { event -> + pendingEvents.add(when (event) { + is Animation.ParticleEvent -> ParticleEvent( + entity, + event.effect, + event.locator?.boxName?.let { locators[it] } ?: parentLocator, + event.preEffectScript, + ) + is Animation.SoundEvent -> SoundEvent( + entity, + event.effect, + ) + }) + } + } + } + } + + inner class AnimationState( + val animation: Animation + ) : MolangQueryAnimation, MolangQueryEntity by entity { + val context = MolangContext(this) + private val animStartTime = entity.lifeTime + override val animTime: Float + get() = entity.lifeTime - animStartTime + override val animLoopTime: Float + get() = + if (animation.holdOnLastFrame) animTime.coerceAtMost(animation.animationLength) + else animTime % animation.animationLength + + /** Effects up to and including this time have already been emitted. */ + internal var lastEffectTime = Float.NEGATIVE_INFINITY + } + + sealed interface Event { + val timeSource: MolangQueryTime + } + + data class ParticleEvent( + override val timeSource: MolangQueryTime, + val effect: ParticleEffect, + val locator: ParticleSystem.Locator, + val preEffectScript: MolangExpression?, + ) : Event + + data class SoundEvent( + override val timeSource: MolangQueryTime, + val effect: SoundEffect + ) : Event + + private inner class BoneLocator( + override var position: Vec3, + override var rotation: Quaternion, + override var velocity: Vec3 + ) : ParticleSystem.Locator { + override val isValid: Boolean + get() = parentLocator.isValid + } +} + +data class Animation( + val name: String, + val animationLength: Float, + val holdOnLastFrame: Boolean, + val bones: Map, + val effects: TreeMap>, + val affectsPoseParts: Set, +) { + val affectsPose: Boolean = affectsPoseParts.isNotEmpty() + + + constructor( + name: String, + file: AnimationFile.Animation, + bones: List, + particleEffects: Map, + soundEffects: Map, + ) : this( + name, + file.animationLength ?: file.bones.calcAnimationLength(), + file.loop == AnimationFile.Loop.HoldOnLastFrame, + file.bones, + TreeMap(mutableMapOf>().also { events -> + file.particleEffects.forEach { (time, effects) -> + val eventsAtTime = events.getOrPut(time, ::mutableListOf) + for (config in effects) { + eventsAtTime.add(ParticleEvent( + particleEffects[config.effect] ?: continue, + config.locator?.let { locatorName -> bones.find { it.boxName == locatorName } }, + config.preEffectScript, + )) + } + } + file.soundEffects.forEach { (time, effects) -> + val eventsAtTime = events.getOrPut(time, ::mutableListOf) + for (config in effects) { + eventsAtTime.add(SoundEvent(soundEffects[config.effect] ?: continue)) + } + } + }), + bones.flatMapTo(mutableSetOf()) { + if (it.boxName in file.bones) it.affectsPoseParts else emptyList() + }, + ) + + sealed interface Event + + data class ParticleEvent( + val effect: ParticleEffect, + val locator: Bone?, + val preEffectScript: MolangExpression?, + ) : Event + + data class SoundEvent( + val effect: SoundEffect, + ) : Event + + companion object { + private fun Map.calcAnimationLength(): Float { + return maxOfOrNull { (_, channels) -> + listOf(channels.position, channels.rotation, channels.scale).maxOfOrNull { + it?.frames?.lastKey() ?: 0f + } ?: 0f + } ?: 0f + } + } +} + +@Serializable +data class Channels( + val position: Keyframes? = null, + val rotation: Keyframes? = null, + val scale: Keyframes? = null, + @SerialName("relative_to") + val relativeTo: RelativeTo = RelativeTo(), +) + +@Serializable +data class RelativeTo( + val rotation: String? = null, +) + +@Serializable(with = KeyframesSerializer::class) +data class Keyframes( + val frames: TreeMap +) { + fun eval(context: MolangContext): Vec3 { + val animTime = (context.query as? MolangQueryAnimation)?.animLoopTime ?: 0f + val floor = frames.floorEntry(animTime) + val ceil = frames.ceilingEntry(animTime) + val floorValue = floor?.value?.post?.eval(context) + val ceilValue = ceil?.value?.pre?.eval(context) + return when { + floorValue == null -> ceilValue!! + ceilValue == null -> floorValue + floor == ceil -> floorValue + floor.value.smooth || ceil.value.smooth -> { + val beforeFloor = frames.lowerEntry(floor.key) + val afterCeil = frames.higherEntry(ceil.key) + val beforeFloorValue = beforeFloor?.value?.post?.eval(context) ?: floorValue + val afterCeilValue = afterCeil?.value?.post?.eval(context) ?: ceilValue + val t = (animTime - floor.key) / (ceil.key - floor.key) + catmullRom(t, beforeFloorValue, floorValue, ceilValue, afterCeilValue) + } + floorValue == ceilValue -> floorValue + else -> floorValue.lerp(ceilValue, (animTime - floor.key) / (ceil.key - floor.key)) + } + } +} + +fun Vec3.lerp(other: Vec3, alpha: Float): Vec3 = + vec3(x.lerp(other.x, alpha), y.lerp(other.y, alpha), z.lerp(other.z, alpha)) + +fun Float.lerp(other: Float, alpha: Float) = this + (other - this) * alpha + +fun catmullRom( + t: Float, + a: Vec3, + b: Vec3, + c: Vec3, + d: Vec3, +): Vec3 { + return vec3( + catmullRom(t, a.x, b.x, c.x, d.x), + catmullRom(t, a.y, b.y, c.y, d.y), + catmullRom(t, a.z, b.z, c.z, d.z), + ) +} + +fun catmullRom( + t: Float, + a: Float, + b: Float, + c: Float, + d: Float, +): Float { + val v0 = -0.5f * a + 1.5f * b - 1.5f * c + 0.5f * d + val v1 = a - 2.5f * b + 2 * c - 0.5f * d + val v2 = -0.5f * a + 0.5f * c + val tt = t * t + return v0 * t * tt + v1 * tt + v2 * t + b +} + +fun bezier( + t: Float, + a: Float, + b: Float, + c: Float, + d: Float, +): Float { + val ab = a.lerp(b, t) + val bc = b.lerp(c, t) + val cd = c.lerp(d, t) + val abc = ab.lerp(bc, t) + val bcd = bc.lerp(cd, t) + return abc.lerp(bcd, t) +} + +@Serializable(with = KeyframeSerializer::class) +data class Keyframe( + val pre: MolangVec3, + val post: MolangVec3, + /** Sections around the keyframe are interpolated using Catmull-Rom splines instead of linear interpolation. */ + val smooth: Boolean, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/BedrockModel.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/BedrockModel.kt new file mode 100644 index 0000000..d074231 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/BedrockModel.kt @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import dev.folomeev.kotgl.matrix.matrices.mutables.inverse +import dev.folomeev.kotgl.matrix.matrices.mutables.times +import dev.folomeev.kotgl.matrix.matrices.mutables.timesSelf +import dev.folomeev.kotgl.matrix.vectors.vec4 +import gg.essential.cosmetics.events.AnimationEvent +import gg.essential.model.EnumPart.Companion.fromBoneName +import gg.essential.model.backend.PlayerPose +import gg.essential.model.backend.RenderBackend +import gg.essential.model.file.AnimationFile +import gg.essential.model.file.ModelFile +import gg.essential.model.file.ParticlesFile +import gg.essential.model.file.SoundDefinitionsFile +import gg.essential.model.util.Quaternion +import gg.essential.model.util.UMatrixStack +import gg.essential.model.util.getRotationEulerZYX +import gg.essential.model.util.times +import gg.essential.network.cosmetics.Cosmetic +import kotlin.jvm.JvmField + +// TODO clean up +class BedrockModel( + val cosmetic: Cosmetic, + val variant: String, + data: ModelFile?, + animationData: AnimationFile?, + particleData: List, + soundData: SoundDefinitionsFile?, + var texture: RenderBackend.Texture?, +) { + @JvmField + var boundingBoxes: List> + var rootBone: Bone + var textureFrameCount = 1 + var animations: List + var animationEvents: List + + // Stores all the different bone sides that are configured in this model + val sideOptions: Set + // Stores whether this model contains bones that hide on a specific side + val isContainsSideOption: Boolean + get() = sideOptions.isNotEmpty() + + init { + val texture = texture + if (data != null) { + val parser = ModelParser(cosmetic, texture?.width ?: 64, texture?.height ?: 64) + parser.parse(data) + rootBone = parser.rootBone + boundingBoxes = parser.boundingBoxes + textureFrameCount = parser.textureFrameCount + } else { + rootBone = Bone("_root") + boundingBoxes = emptyList() + } + + sideOptions = getBones(rootBone).mapNotNull { it.side }.toSet() + + val particleEffects = mutableMapOf() + if (data != null) { + for (config in particleData.map { it.particleEffect }) { + val material = config.description.basicRenderParameters.material + particleEffects[config.description.identifier] = + ParticleEffect(material, config.components, config.curves, config.events, texture, particleEffects) + } + } + + val soundEffects = mutableMapOf() + if (soundData != null) { + for ((identifier, definition) in soundData.definitions) { + val sounds = definition.sounds.mapNotNull { sound -> + val asset = cosmetic.assets(variant).files[sound.name + ".ogg"] ?: return@mapNotNull null + SoundEffect.Entry(asset, sound.stream, sound.volume, sound.pitch, sound.directional, sound.weight) + } + soundEffects[identifier] = + SoundEffect(identifier, definition.category, definition.minDistance, definition.maxDistance, sounds) + } + } + + if (animationData != null) { + animations = animationData.animations.map { Animation(it.key, it.value, getBones(rootBone), particleEffects, soundEffects) } + animationEvents = animationData.triggers + } else { + animations = emptyList() + animationEvents = emptyList() + } + } + + fun getAnimationByName(name: String): Animation? { + for (animation in animations) { + if (animation.name == name) { + return animation + } + } + return null + } + + fun computePose(basePose: PlayerPose, animationState: ModelAnimationState): PlayerPose { + if (animationState.active.none { it.animation.affectsPose }) { + return basePose + } + animationState.apply(rootBone, true) + applyPose(rootBone, basePose) + return retrievePose(rootBone, basePose) + } + + fun applyPose(rootBone: Bone, pose: PlayerPose) { + var anyGimbal = false + for (bone in getBones(rootBone)) { + if (bone.gimbal) { + anyGimbal = true + } + val part = fromBoneName(bone.boxName) ?: continue + copy(pose[part], bone, OFFSETS.getValue(part)) + if (pose.child) { + if (part == EnumPart.HEAD) { + bone.childScale = 0.75f + bone.animOffsetY -= 8f + } else { + bone.childScale = 0.5f + } + } + } + // TODO maybe optimize traversal, don't need to compute subtrees that do not even have any gimbal parts + if (anyGimbal) { + rootBone.propagateGimbal(Quaternion.Identity) + } + } + + fun retrievePose(rootBone: Bone, basePose: PlayerPose): PlayerPose { + val parts = basePose.toMap(mutableMapOf()) + + fun Bone.visit(matrixStack: UMatrixStack, parentHasScaling: Boolean) { + val part = fromBoneName(boxName) + + if (part == null && childModels.isEmpty()) { + return + } + + val hasScaling = parentHasScaling || animScaleX != 1f || animScaleY != 1f || animScaleZ != 1f + + matrixStack.push() + matrixStack.translate(pivotX + animOffsetX, pivotY - animOffsetY, pivotZ + animOffsetZ) + if (gimbal) { + matrixStack.rotate(parentRotation.conjugate()) + } + matrixStack.rotate(rotateAngleZ + animRotZ, 0.0f, 0.0f, 1.0f, false) + matrixStack.rotate(rotateAngleY + animRotY, 0.0f, 1.0f, 0.0f, false) + matrixStack.rotate(rotateAngleX + animRotX, 1.0f, 0.0f, 0.0f, false) + extra?.let { + matrixStack.peek().model.timesSelf(it) + } + matrixStack.scale(animScaleX, animScaleY, animScaleZ) + matrixStack.translate(-pivotX - userOffsetX, -pivotY - userOffsetY, -pivotZ - userOffsetZ) + + if (part != null) { + val offset = OFFSETS.getValue(part) + val matrix = matrixStack.peek().model + + // We can easily get the local pivot point by simply undoing the last `matrixStack.translate` call + // (ignoring user offset for now because that is unused for emotes) + val localPivot = vec4(pivotX, pivotY, pivotZ, 1f) + // We can transform that into global space by simply passing it through the matrix + val globalPivot = localPivot.times(matrix) + + // Local rotation is even simpler because there is no residual local rotation "pivot", so our global + // rotation is simply the rotation of the matrix stack. + val globalRotation = matrix.getRotationEulerZYX() + + // We only need to compute the scale/shear matrix if there was some scaling, otherwise we'll just end + // up with an identity (within rounding errors) matrix and do a bunch of extra work (here and when + // applying it to other cosmetics) which we don't really need to do. + val extra = if (!hasScaling) { + null + } else { + // To compute the scale/shear matrix, we need to convert the global pivot and rotation back into a + // matrix so we can then compute the difference between that and what we actually want to have + val resultStack = UMatrixStack() + resultStack.translate(globalPivot.x, globalPivot.y, globalPivot.z) + resultStack.rotate(globalRotation.z, 0.0f, 0.0f, 1.0f, false) + resultStack.rotate(globalRotation.y, 0.0f, 1.0f, 0.0f, false) + resultStack.rotate(globalRotation.x, 1.0f, 0.0f, 0.0f, false) + // To compute the difference, we also need to undo the final translate on the current stack because + // the extra matrix is applied before that, right after rotation (because the MC renderer doesn't do + // that final translate). + val expectedStack = matrixStack.fork() + expectedStack.translate(pivotX, pivotY, pivotZ) + // The final transform for a given bone in other cosmetics will end up being + // M = R * X + // where + // M is the final transform, this should end up matching the `expectedStack` computed above + // R is the combination of translation and rotation, this will match `resultStack` computed above + // X is a remainder of scale/shear transformations, this is what we need to compute and store + // To do so, we simply multiply by the inverse of R (denoted by R') from the left on both sides. + // The Rs on the right side will cancel out and we're left with: + // R' * M = X + // or + // X = R' * M + // which we can easily compute as follows: + resultStack.peek().model.inverse().times(expectedStack.peek().model) + } + + parts[part] = PlayerPose.Part( + // As per the matrix stack transformations above, if we assume that this point doesn't have any + // parent (as would be the case for regular cosmetics), the global pivot point can be computed as: + // (a) globalPivot = bone.pivot + bone.animOffset + // As per above [copy] method, the right side variables are set as: + // (b) bone.pivot = offset.pivot + // (c) bone.animOffset = pose.pivot + offset.offset + // We're trying to compute the `pose.pivot` value we need to store to replicate in other cosmetics + // the `globalPivot` value observed above. + // We can first rearrange the above equations as: + // (a) bone.animOffset = globalPivot - offset.pivot + // (b) bone.offset = bone.pivot + // (c) pose.pivot = bone.animOffset - bone.offset + // Substituting (a) and (b) into (c) gives us the result we're looking for: + // pose.pivot = globalPivot - offset.pivot - offset.offset + // A few of the signs get flipped for Y, the details of that are left as an exercise to the reader. + pivotX = (globalPivot.x - offset.pivotX - offset.offsetX), + pivotY = (globalPivot.y - offset.pivotY + offset.offsetY), + pivotZ = (globalPivot.z - offset.pivotZ - offset.offsetZ), + // Global rotation, again, is easier because we don't have to deal with any offsets + rotateAngleX = globalRotation.x, + rotateAngleY = globalRotation.y, + rotateAngleZ = globalRotation.z, + extra = extra, + ) + } + + for (childModel in childModels) { + childModel.visit(matrixStack, hasScaling) + } + + matrixStack.pop() + } + + rootBone.visit(UMatrixStack(), false) + + return PlayerPose.fromMap(parts, basePose.child) + } + + /** + * Renders the model + * + * @param matrixStack + */ + fun render( + matrixStack: UMatrixStack, + vertexConsumerProvider: RenderBackend.VertexConsumerProvider, + rootBone: Bone, + metadata: RenderMetadata, + lifetime: Float, + ) { + val textureLocation = texture ?: metadata.skin + + val totalFrames = textureFrameCount.toFloat() + val frame = (lifetime * TEXTURE_ANIMATION_FPS).toInt() + val offset = frame % totalFrames / totalFrames + + val pose = metadata.pose + if (pose != null) { + applyPose(rootBone, pose) + } + + propagateVisibilityToRootBone(metadata.side, + rootBone, + metadata.hiddenBones, + metadata.parts, + ) + + vertexConsumerProvider.provide(textureLocation) { vertexConsumer -> + rootBone.render(matrixStack, vertexConsumer, metadata.light, metadata.scale, offset) + } + } + + fun getBones(bone: Bone): List { + val bones = mutableListOf(bone) + for (childModel in bone.childModels) { + bones.addAll(getBones(childModel)) + } + return bones + } + + /** + * Propagates visibility to the root bone with the given [side] or default side if null and required + */ + fun propagateVisibilityToRootBone( + side: Side?, + rootBone: Bone, + hiddenBones: Set, + parts: Set?, + ) { + // If this cosmetic has bones with the side option, we want to force our default side + // otherwise both sides will show + val updatedSide = side ?: cosmetic.defaultSide ?: Side.getDefaultSideOrNull(sideOptions) + + for (bone in getBones(rootBone)) { + val part = fromBoneName(bone.boxName) + if (part == null) { + bone.visible = if (bone.boxName in hiddenBones) false else null + continue + } + bone.visible = (parts == null || part in parts) && bone.boxName !in hiddenBones + } + rootBone.propagateVisibility(true, updatedSide) + } + + private fun copy(pose: PlayerPose.Part, bone: Bone, offset: Offset) { + bone.rotateAngleX = pose.rotateAngleX + bone.rotateAngleY = pose.rotateAngleY + bone.rotateAngleZ = pose.rotateAngleZ + bone.pivotX = offset.pivotX + bone.pivotY = offset.pivotY + bone.pivotZ = offset.pivotZ + bone.animOffsetX += pose.pivotX + offset.offsetX + bone.animOffsetY += -pose.pivotY + offset.offsetY + bone.animOffsetZ += pose.pivotZ + offset.offsetZ + bone.extra = pose.extra + bone.isHidden = false + bone.childScale = 1f + } + + private class Offset( + val pivotX: Float, + val pivotY: Float, + val pivotZ: Float, + val offsetX: Float, + val offsetY: Float, + val offsetZ: Float + ) + + companion object { + private val BASE = Offset(0f, -24f, 0f, 0f, 0f, 0f) + private val RIGHT_ARM = Offset(-5f, -22f, 0f, 5f, 2f, 0f) + private val LEFT_ARM = Offset(5f, -22f, 0f, -5f, 2f, 0f) + private val LEFT_LEG = Offset(1.9f, -12f, 0f, -1.9f, 12f, 0f) + private val RIGHT_LEG = Offset(-1.9f, -12f, 0f, 1.9f, 12f, 0f) + private val CAPE = Offset(0f, -24f, 2.5f, 0f, 0f, -2f) + private val OFFSETS = + mapOf( + EnumPart.HEAD to BASE, + EnumPart.BODY to BASE, + EnumPart.LEFT_ARM to LEFT_ARM, + EnumPart.RIGHT_ARM to RIGHT_ARM, + EnumPart.LEFT_LEG to LEFT_LEG, + EnumPart.RIGHT_LEG to RIGHT_LEG, + EnumPart.LEFT_SHOULDER_ENTITY to BASE, + EnumPart.RIGHT_SHOULDER_ENTITY to BASE, + EnumPart.LEFT_WING to BASE, + EnumPart.RIGHT_WING to BASE, + EnumPart.CAPE to CAPE, + ) + const val TEXTURE_ANIMATION_FPS = 7f + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/Bone.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/Bone.kt new file mode 100644 index 0000000..46a5525 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/Bone.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import dev.folomeev.kotgl.matrix.matrices.Mat4 +import dev.folomeev.kotgl.matrix.matrices.mutables.timesSelf +import dev.folomeev.kotgl.matrix.matrices.mutables.toMutable +import dev.folomeev.kotgl.matrix.vectors.vecUnitX +import dev.folomeev.kotgl.matrix.vectors.vecUnitY +import dev.folomeev.kotgl.matrix.vectors.vecUnitZ +import gg.essential.model.util.Quaternion +import gg.essential.model.util.UMatrixStack +import gg.essential.model.util.UVertexConsumer +import kotlin.jvm.JvmField + +// TODO clean up +class Bone( + @JvmField + val boxName: String +) { + var textureWidth = 64 + var textureHeight = 32 + @JvmField + var pivotX = 0f + @JvmField + var pivotY = 0f + @JvmField + var pivotZ = 0f + @JvmField + var rotateAngleX = 0f + @JvmField + var rotateAngleY = 0f + @JvmField + var rotateAngleZ = 0f + var extra: Mat4? = null + var mirror = false + var showModel = true + @JvmField + var isHidden = false + @JvmField + var cubeList = mutableListOf() + @JvmField + var childModels = mutableListOf() + @JvmField + var animOffsetX = 0f + @JvmField + var animOffsetY = 0f + @JvmField + var animOffsetZ = 0f + var animRotX = 0f + var animRotY = 0f + var animRotZ = 0f + var animScaleX = 0f + var animScaleY = 0f + var animScaleZ = 0f + @JvmField + var userOffsetX = 0f + @JvmField + var userOffsetY = 0f + @JvmField + var userOffsetZ = 0f + @JvmField + var childScale = 1f + var side: Side? = null + @JvmField + var visible: Boolean? = null // determines visibility for all bones in this tree unless overwritten in a child + private var isVisible = true // actual visibility for this specific bone, set in propagateVisibility + private var fullyInvisible = false // propagateVisibility has determined that we can skip this entire tree + + /** Whether an animation targeting this bone will have an effect on the player pose. */ + var affectsPose = false // initialized by ModelParser + /** Which parts of the player pose will be affected by an animation targeting this bone. */ + var affectsPoseParts = emptySet() // initialized by ModelParser + + /** Whether this bone should undo all parent rotation, as if it was stabilized by three gimbals. */ + var gimbal = false + /** Quaternion representing the rotation of all parents to be undone if [gimbal] is `true` */ + var parentRotation: Quaternion = Quaternion.Identity + + init { + resetAnimationOffsets(false) + } + + fun addChild(child: Bone) { + childModels.add(child) + } + + fun propagateVisibility(parentVisible: Boolean, side: Side?) { + if (this.side != null && side != null && this.side !== side) { + isVisible = false + fullyInvisible = true + return + } + val isVisible = if (visible == null) parentVisible else visible!! + var fullyInvisible = !isVisible + for (child in childModels) { + child.propagateVisibility(isVisible, side) + fullyInvisible = fullyInvisible and child.fullyInvisible + } + this.isVisible = isVisible + this.fullyInvisible = fullyInvisible + } + + fun resetAnimationOffsets(recursive: Boolean) { + animOffsetZ = 0f + animOffsetY = animOffsetZ + animOffsetX = animOffsetY + animRotZ = 0f + animRotY = animRotZ + animRotX = animRotY + animScaleZ = 1f + animScaleY = animScaleZ + animScaleX = animScaleY + gimbal = false + if (recursive) { + for (childModel in childModels) { + childModel.resetAnimationOffsets(true) + } + } + } + + fun render( + matrixStack: UMatrixStack, + renderer: UVertexConsumer, + light: Int, + scale: Float, + verticalUVOffset: Float + ) { + if (!isHidden && showModel && !fullyInvisible) { + matrixStack.push() + matrixStack.scale(childScale, childScale, childScale) + val translateX = pivotX * scale + animOffsetX * scale + val translateY = pivotY * scale - animOffsetY * scale + val translateZ = pivotZ * scale + animOffsetZ * scale + matrixStack.translate(translateX, translateY, translateZ) + if (gimbal) { + matrixStack.rotate(parentRotation.conjugate()) + } + matrixStack.rotate(rotateAngleZ + animRotZ, 0.0f, 0.0f, 1.0f, false) + matrixStack.rotate(rotateAngleY + animRotY, 0.0f, 1.0f, 0.0f, false) + matrixStack.rotate(rotateAngleX + animRotX, 1.0f, 0.0f, 0.0f, false) + extra?.let { + matrixStack.peek().model.timesSelf(it.toMutable().apply { + m03 *= scale + m13 *= scale + m23 *= scale + }) + } + matrixStack.scale(animScaleX, animScaleY, animScaleZ) + matrixStack.translate( + -pivotX * scale - userOffsetX * scale, + -pivotY * scale - userOffsetY * scale, + -pivotZ * scale - userOffsetZ * scale + ) + if (isVisible) { + for (cube in cubeList) { + cube.render(matrixStack, renderer, light, scale, verticalUVOffset) + } + } + for (childModel in childModels) { + childModel.render(matrixStack, renderer, light, scale, verticalUVOffset) + } + matrixStack.pop() + } + } + + fun setTextureSize(p_setTextureSize_1_: Int, p_setTextureSize_2_: Int) { + textureWidth = p_setTextureSize_1_ + textureHeight = p_setTextureSize_2_ + } + + fun deepCopy(): Bone { + val bone = Bone(boxName) + bone.textureWidth = textureWidth + bone.textureHeight = textureHeight + bone.pivotX = pivotX + bone.pivotY = pivotY + bone.pivotZ = pivotZ + bone.rotateAngleX = rotateAngleX + bone.rotateAngleY = rotateAngleY + bone.rotateAngleZ = rotateAngleZ + bone.cubeList = ArrayList() + for (cube in cubeList) { + bone.cubeList.add(Cube(cube.getQuadList().map { face -> + Face(face.vertexPositions.map { it.copy() }.toTypedArray()) + }, cube.mirror)) + } + for (childModel in childModels) { + bone.addChild(childModel.deepCopy()) + } + bone.affectsPose = affectsPose + bone.affectsPoseParts = affectsPoseParts + bone.side = side + return bone + } + + /** + * Returns true if this bone or any of its children contain visible boxes + */ + fun containsVisibleBoxes(): Boolean { + return !fullyInvisible && ((this.cubeList.isNotEmpty() && isVisible) || this.childModels.any { it.containsVisibleBoxes() }) + } + + fun propagateGimbal(parentRotation: Quaternion) { + if (gimbal) { + // If this is a gimbal, ignore parent and pose rotation, only keep animation rotation + this.rotateAngleX = 0f + this.rotateAngleY = 0f + this.rotateAngleZ = 0f + this.parentRotation = parentRotation + } + + var ownRotation = if (gimbal) Quaternion.Identity else parentRotation + ownRotation *= Quaternion.fromAxisAngle(vecUnitZ(), rotateAngleZ + animRotZ) + ownRotation *= Quaternion.fromAxisAngle(vecUnitY(), rotateAngleY + animRotY) + ownRotation *= Quaternion.fromAxisAngle(vecUnitX(), rotateAngleX + animRotX) + for (child in childModels) { + child.propagateGimbal(ownRotation) + } + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/Box3.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/Box3.kt new file mode 100644 index 0000000..9fd9488 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/Box3.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import kotlin.jvm.JvmOverloads +import kotlin.math.max +import kotlin.math.min + +//Taken from https://github.com/markaren/three.kt +data class Box3 @JvmOverloads constructor( + var min: Vector3 = Vector3(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY), + var max: Vector3 = Vector3(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY) +) { + + private val intersectsTriangleHelper by lazy { IntersectsTriangleHelper() } + + fun set(min: Vector3, max: Vector3): Box3 { + this.min.copy(min) + this.max.copy(max) + + return this + } + + fun setFromPoints(points: List): Box3 { + this.makeEmpty() + + points.forEach { + this.expandByPoint(it) + } + + return this + } + + fun makeEmpty(): Box3 { + this.min.x = Float.POSITIVE_INFINITY + this.min.y = Float.POSITIVE_INFINITY + this.min.z = Float.POSITIVE_INFINITY + + this.max.x = Float.NEGATIVE_INFINITY + this.max.y = Float.NEGATIVE_INFINITY + this.max.z = Float.NEGATIVE_INFINITY + + return this + } + + fun isEmpty(): Boolean { + // this is a more robust check for empty than ( volume <= 0 ) because volume can get positive with two negative axes + return (this.max.x < this.min.x) || (this.max.y < this.min.y) || (this.max.z < this.min.z) + } + + @JvmOverloads + fun getCenter(target: Vector3 = Vector3()): Vector3 { + return if (this.isEmpty()) { + target.set(0f, 0f, 0f) + } else { + target.addVectors(this.min, this.max).multiplyScalar(0.5f) + } + } + + @JvmOverloads + fun getSize(target: Vector3 = Vector3()): Vector3 { + return if (this.isEmpty()) { + target.set(0f, 0f, 0f) + } else { + target.subVectors(this.max, this.min) + } + } + + fun expandByPoint(point: Vector3): Box3 { + this.min.min(point) + this.max.max(point) + + return this + } + + fun expandByScalar(scalar: Float): Box3 { + this.min.addScalar(-scalar) + this.max.addScalar(scalar) + + return this + } + + @JvmOverloads + fun getParameter(point: Vector3, target: Vector3 = Vector3()): Vector3 { + return target.set( + (point.x - this.min.x) / (this.max.x - this.min.x), + (point.y - this.min.y) / (this.max.y - this.min.y), + (point.z - this.min.z) / (this.max.z - this.min.z) + ) + } + + + + fun intersect(box: Box3): Box3 { + this.min.max(box.min) + this.max.min(box.max) + + // ensure that if there is no overlap, the result is fully empty, not slightly empty with non-inf/+inf values that will cause subsequence intersects to erroneously return valid values. + if (this.isEmpty()) { + this.makeEmpty() + } + + return this + } + + + fun translate(offset: Vector3): Box3 { + this.min.add(offset) + this.max.add(offset) + + return this + } + + + + fun clone(): Box3 { + return Box3().copy(this) + } + + fun copy(box: Box3): Box3 { + this.min.copy(box.min) + this.max.copy(box.max) + + return this + } + + companion object { + + private val points by lazy { + List(8) { Vector3() } + } + + } + + private inner class IntersectsTriangleHelper { + + // triangle centered vertices + var v0 = Vector3() + var v1 = Vector3() + var v2 = Vector3() + + // triangle edge vectors + var f0 = Vector3() + var f1 = Vector3() + var f2 = Vector3() + + var testAxis = Vector3() + + var center = Vector3() + var extents = Vector3() + + var triangleNormal = Vector3() + + } + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/Cube.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/Cube.kt new file mode 100644 index 0000000..ffbb589 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/Cube.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.model.util.UMatrixStack +import gg.essential.model.util.UVertexConsumer + +// TODO clean up +class Cube { + /** + * X vertex coordinate of lower box corner + */ + var posX1 = 0f + + /** + * Y vertex coordinate of lower box corner + */ + var posY1 = 0f + + /** + * Z vertex coordinate of lower box corner + */ + var posZ1 = 0f + + /** + * X vertex coordinate of upper box corner + */ + var posX2 = 0f + + /** + * Y vertex coordinate of upper box corner + */ + var posY2 = 0f + + /** + * Z vertex coordinate of upper box corner + */ + var posZ2 = 0f + + /** + * An arraylist of TexturedQuads. Min of two per face for each orientation but can be more depending on intersections + */ + private val quadList = mutableListOf() + + var boxName: String? = null + val mirror: Boolean + + constructor( + renderer: Bone, + texU: Float, + texV: Float, + x: Float, + y: Float, + z: Float, + dx: Float, + dy: Float, + dz: Float, + delta: Float, + mirror: Boolean + ) : this(renderer, x, y, z, dx, dy, dz, delta, mirror, texU, texV, null) + + constructor( + renderer: Bone, + x: Float, + y: Float, + z: Float, + dx: Float, + dy: Float, + dz: Float, + delta: Float, + mirror: Boolean, + uvData: CubeUvData? + ) : this(renderer, x, y, z, dx, dy, dz, delta, mirror, 0f, 0f, uvData) + + constructor(precomputedFaces: List, mirror: Boolean) { + quadList.addAll(precomputedFaces) + this.mirror = mirror + } + + private constructor( + renderer: Bone, + x: Float, y: Float, z: Float, + dx: Float, dy: Float, dz: Float, + delta: Float, mirror: Boolean, + texU: Float, texV: Float, // these are only used if uvData is null + uvData: CubeUvData? + ) { + var x = x + var y = y + var z = z + posX1 = x + posY1 = y + posZ1 = z + posX2 = x + dx + posY2 = y + dy + posZ2 = z + dz + var x2 = x + dx + var y2 = y + dy + var z2 = z + dz + x = x - delta + y = y - delta + z = z - delta + x2 = x2 + delta + y2 = y2 + delta + z2 = z2 + delta + if (mirror) { + val f3 = x2 + x2 = x + x = f3 + } + val PositionTexVertex7 = PositionTexVertex(x, y, z, 0.0f, 0.0f) + val PositionTexVertex = PositionTexVertex(x2, y, z, 0.0f, 8.0f) + val PositionTexVertex1 = PositionTexVertex(x2, y2, z, 8.0f, 8.0f) + val PositionTexVertex2 = PositionTexVertex(x, y2, z, 8.0f, 0.0f) + val PositionTexVertex3 = PositionTexVertex(x, y, z2, 0.0f, 0.0f) + val PositionTexVertex4 = PositionTexVertex(x2, y, z2, 0.0f, 8.0f) + val PositionTexVertex5 = PositionTexVertex(x2, y2, z2, 8.0f, 8.0f) + val PositionTexVertex6 = PositionTexVertex(x, y2, z2, 8.0f, 0.0f) + val DEFAULT_UV_NORTH = floatArrayOf(texU + dz, texV + dz, texU + dz + dx, texV + dz + dy) + val DEFAULT_UV_EAST = floatArrayOf(texU, texV + dz, texU + dz, texV + dz + dy) + val DEFAULT_UV_SOUTH = floatArrayOf(texU + dz + dx + dz, texV + dz, texU + dz + dx + dz + dx, texV + dz + dy) + val DEFAULT_UV_WEST = floatArrayOf(texU + dz + dx, texV + dz, texU + dz + dx + dz, texV + dz + dy) + val DEFAULT_UV_UP = floatArrayOf(texU + dz, texV, texU + dz + dx, texV + dz) + val DEFAULT_UV_DOWN = floatArrayOf(texU + dz + dx, texV + dz, texU + dz + dx + dx, texV) + val north = uvData?.north ?: DEFAULT_UV_NORTH + val east = uvData?.east ?: DEFAULT_UV_EAST + val south = uvData?.south ?: DEFAULT_UV_SOUTH + val west = uvData?.west ?: DEFAULT_UV_WEST + val up = uvData?.up ?: DEFAULT_UV_UP + val down = uvData?.down ?: DEFAULT_UV_DOWN + quadList.add(Face(arrayOf(PositionTexVertex4, PositionTexVertex, PositionTexVertex1, PositionTexVertex5), west[0], west[1], west[2], west[3], renderer.textureWidth.toFloat(), renderer.textureHeight.toFloat())) //+x + quadList.add(Face(arrayOf(PositionTexVertex7, PositionTexVertex3, PositionTexVertex6, PositionTexVertex2), east[0], east[1], east[2], east[3], renderer.textureWidth.toFloat(), renderer.textureHeight.toFloat())) //-x + quadList.add(Face(arrayOf(PositionTexVertex4, PositionTexVertex3, PositionTexVertex7, PositionTexVertex), up[0], up[1], up[2], up[3], renderer.textureWidth.toFloat(), renderer.textureHeight.toFloat())) + quadList.add(Face(arrayOf(PositionTexVertex1, PositionTexVertex2, PositionTexVertex6, PositionTexVertex5), down[0], down[1], down[2], down[3], renderer.textureWidth.toFloat(), renderer.textureHeight.toFloat())) + quadList.add(Face(arrayOf(PositionTexVertex, PositionTexVertex7, PositionTexVertex2, PositionTexVertex1), north[0], north[1], north[2], north[3], renderer.textureWidth.toFloat(), renderer.textureHeight.toFloat())) + quadList.add(Face(arrayOf(PositionTexVertex3, PositionTexVertex4, PositionTexVertex5, PositionTexVertex6), south[0], south[1], south[2], south[3], renderer.textureWidth.toFloat(), renderer.textureHeight.toFloat())) + + // TODO could skip generation of inverted faces when we know the corresponding texture to be fully opaque + for (i in 0..5) { + val (a, b, c, d) = quadList[i].vertexPositions + quadList.add(Face(arrayOf(b, a, d, c))) + } + this.mirror = mirror + if (mirror) { + for (texturedquad in quadList) { + texturedquad.flipFace() + } + } + } + + fun render( + matrixStack: UMatrixStack, + renderer: UVertexConsumer, + light: Int, + scale: Float, + verticalUVOffset: Float + ) { + for (texturedquad in quadList) { + texturedquad.draw(matrixStack, renderer, light, scale, verticalUVOffset) + } + } + + fun setBoxName(name: String?): Cube { + boxName = name + return this + } + + fun getQuadList(): MutableList { + return quadList + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/CubeUvData.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/CubeUvData.kt new file mode 100644 index 0000000..61d98e9 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/CubeUvData.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +class CubeUvData( + val north: FloatArray, + val east: FloatArray, + val south: FloatArray, + val west: FloatArray, + val up: FloatArray, + val down: FloatArray, +) diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/EnumPart.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/EnumPart.kt new file mode 100644 index 0000000..3b60095 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/EnumPart.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import kotlin.jvm.JvmStatic + +/** Different bones cosmetics can be bound to */ +enum class EnumPart( + + /** represents the armor slot IDs that the part covers */ + val armorSlotIds: Set, +) { + HEAD(setOf(3)), + BODY(setOf(2)), + RIGHT_ARM(setOf(2)), + LEFT_ARM(setOf(2)), + LEFT_LEG(setOf(0, 1)), + RIGHT_LEG(setOf(0, 1)), + // Parrots + RIGHT_SHOULDER_ENTITY(emptySet()), + LEFT_SHOULDER_ENTITY(emptySet()), + // Elytra + RIGHT_WING(emptySet()), + LEFT_WING(emptySet()), + // Misc + CAPE(emptySet()), + ; + + companion object { + @JvmStatic + fun fromBoneName(name: String): EnumPart? { + // Don't use .toLowercase since it will create a ton more objects + return when (name) { + "rightArm", "arm_right", "right_arm", "RightArm", + "__arm_right__" -> RIGHT_ARM + "leftArm", "arm_left", "left_arm", "LeftArm", + "__arm_left__" -> LEFT_ARM + "body", "Body", + "__body__" -> BODY + "leftLeg", "leg_left", "left_leg", "LeftLeg", + "__leg_left__"-> LEFT_LEG + "rightLeg", "leg_right", "right_leg", "RightLeg", + "__leg_right__"-> RIGHT_LEG + "Head", "head", + "__head__" -> HEAD + "right_shoulder_entity", + "__shoulder_entity_right__" -> RIGHT_SHOULDER_ENTITY + "left_shoulder_entity", + "__shoulder_entity_left__" -> LEFT_SHOULDER_ENTITY + "right_wing", + "__wing_right__" -> RIGHT_WING + "left_wing", + "__wing_left__" -> LEFT_WING + "cape", + "__cape__" -> CAPE + else -> null + } + } + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/Face.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/Face.kt new file mode 100644 index 0000000..cc64005 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/Face.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.model.util.UMatrixStack +import gg.essential.model.util.UVertexConsumer +import kotlin.jvm.JvmField +import kotlin.math.floor + +// TODO clean up +class Face(@JvmField var vertexPositions: Array) { + private var normal: Vector3 + + init { + val (a, b, c) = vertexPositions.map { it.vector3 } + normal = (c - b).cross(a - b).normalize() + } + + constructor( + vertices: Array, + texcoordU1: Float, + texcoordV1: Float, + texcoordU2: Float, + texcoordV2: Float, + frameWidth: Float, + frameHeight: Float + ) : this(vertices) { + val u1 = floor(texcoordU1) + val v1 = floor(texcoordV1) + val u2 = floor(texcoordU2) + val v2 = floor(texcoordV2) + val du = 0.0f / frameWidth + val dv = 0.0f / frameHeight + vertices[0] = vertices[0].setTexturePosition(u2 / frameWidth - du, v1 / frameHeight + dv) + vertices[1] = vertices[1].setTexturePosition(u1 / frameWidth + du, v1 / frameHeight + dv) + vertices[2] = vertices[2].setTexturePosition(u1 / frameWidth + du, v2 / frameHeight - dv) + vertices[3] = vertices[3].setTexturePosition(u2 / frameWidth - du, v2 / frameHeight - dv) + } + + fun flipFace() { + vertexPositions = vertexPositions.mapIndexed { i, _ -> + vertexPositions[vertexPositions.size - i - 1] + }.toTypedArray() + normal = Vector3().sub(normal) + } + + /** + * Draw this primitve. This is typically called only once as the generated drawing instructions are saved by the + * renderer and reused later. + */ + fun draw( + matrixStack: UMatrixStack, + buffer: UVertexConsumer, + light: Int, + scale: Float, + verticalUVOffset: Float + ) { + val nx = normal.x + val ny = normal.y + val nz = normal.z + for (i in 0..3) { + val PositionTexVertex = vertexPositions[i] + buffer.pos( + matrixStack, + (PositionTexVertex.vector3.x * scale).toDouble(), + (PositionTexVertex.vector3.y * scale).toDouble(), + (PositionTexVertex.vector3.z * scale).toDouble(), + ) + buffer.tex( + PositionTexVertex.texturePositionX.toDouble(), + (PositionTexVertex.texturePositionY + verticalUVOffset).toDouble(), + ) + buffer.norm(matrixStack, nx, ny, nz) + buffer.endVertex() + } + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/ModelInstance.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/ModelInstance.kt new file mode 100644 index 0000000..298e8c7 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/ModelInstance.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.cosmetics.events.AnimationTarget +import gg.essential.cosmetics.state.EssentialAnimationSystem +import gg.essential.cosmetics.state.TextureAnimationSync +import gg.essential.cosmetics.state.WearableLocator +import gg.essential.model.EnumPart.Companion.fromBoneName +import gg.essential.model.backend.PlayerPose +import gg.essential.model.backend.RenderBackend +import gg.essential.model.molang.MolangQueryEntity +import gg.essential.model.util.UMatrixStack +import gg.essential.network.cosmetics.Cosmetic + +class ModelInstance( + var model: BedrockModel, + val entity: MolangQueryEntity, + val animationTargets: Set, + val onAnimation: (String) -> Unit, +) { + val locator = WearableLocator(entity.locator) + var animationState = ModelAnimationState(entity, locator) + var textureAnimationSync = TextureAnimationSync(model.textureFrameCount) + var essentialAnimationSystem = EssentialAnimationSystem(model, entity, animationState, textureAnimationSync, animationTargets, onAnimation) + + fun switchModel(newModel: BedrockModel) { + val newTextureAnimation = model.textureFrameCount != newModel.textureFrameCount + val newAnimations = model.animations != newModel.animations || model.animationEvents != newModel.animationEvents + + model = newModel + + if (newTextureAnimation) { + textureAnimationSync = TextureAnimationSync(model.textureFrameCount) + } + if (newAnimations) { + animationState = ModelAnimationState(entity, locator) + } + if (newAnimations || newTextureAnimation) { + essentialAnimationSystem = EssentialAnimationSystem(model, entity, animationState, textureAnimationSync, animationTargets, onAnimation) + } + } + + val cosmetic: Cosmetic + get() = model.cosmetic + + fun computePose(basePose: PlayerPose): PlayerPose { + return model.computePose(basePose, animationState) + } + + /** + * Emits new effects and updates all locators bound to this model instance. + * + * This method needs to be called for models that were not rendered as part of the normal render pass (e.g. because + * the corresponding player was frustum culled), so that particles bound to locators (which may be visible even + * when the player entity that spawned them is not) are update correctly. + * It is safe to call on all models and will simply return without any changes if the model has already been + * rendered this frame. + * + * Note that new effects are merely emitted into [ModelAnimationState.pendingParticles]. + * The caller needs to collect them from there and forward them to the actual particle system. + */ + fun updateEffects() { + animationState.updateEffects() + + // Locators are fairly expensive to update, so only do it if we need to + if (animationState.locatorsNeedUpdating()) { + val pose = PlayerPose.neutral() // no way for us to get the real pose if we didn't actually render + val rootBone = model.rootBone + animationState.apply(rootBone, false) + model.applyPose(rootBone, pose) + animationState.updateLocators(rootBone, 1 / 16f) + } + } + + fun render( + matrixStack: UMatrixStack, + vertexConsumerProvider: RenderBackend.VertexConsumerProvider, + rootBone: Bone, + renderMetadata: RenderMetadata, + ) { + essentialAnimationSystem.maybeFireTextureAnimationStartEvent() + essentialAnimationSystem.updateAnimationState() + animationState.apply(rootBone, false) + + for (bone in model.getBones(rootBone)) { + if (fromBoneName(bone.boxName) != null) { + bone.userOffsetX = renderMetadata.positionAdjustment.x + bone.userOffsetY = renderMetadata.positionAdjustment.y + bone.userOffsetZ = renderMetadata.positionAdjustment.z + } + } + + model.render( + matrixStack, + vertexConsumerProvider, + rootBone, + renderMetadata, + textureAnimationSync.getAdjustedLifetime(entity.lifeTime), + ) + + animationState.updateEffects() + animationState.updateLocators(rootBone, renderMetadata.scale) + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/ModelParser.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/ModelParser.kt new file mode 100644 index 0000000..509e4e7 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/ModelParser.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.mod.cosmetics.CosmeticSlot +import gg.essential.model.file.ModelFile +import gg.essential.network.cosmetics.Cosmetic +import kotlin.math.PI + +class ModelParser( + private val cosmetic: Cosmetic, + private val textureWidth: Int, + private val textureHeight: Int, +) { + private val boneByName = mutableMapOf() + + val boundingBoxes = mutableListOf>() + val rootBone = makeBone("_root") + var textureFrameCount = 1 + + private fun makeBone(name: String): Bone { + val bone = Bone(name) + bone.textureWidth = textureWidth + bone.textureHeight = textureHeight + + boneByName[name] = bone + + return bone + } + + fun parse(file: ModelFile) { + val geometry = file.geometries.firstOrNull() ?: return + + textureFrameCount = (textureHeight / geometry.description.textureHeight).coerceAtLeast(1) + + val extraInflate = when { + cosmetic.type.id == "PLAYER" -> 0f + else -> ((EXTRA_INFLATE_GROUPS.find { it.value.contains(cosmetic.type.slot) }?.index ?: 0) * 0.01f) + 0.01f + } + + for (bone in geometry.bones) { + if (bone.name.startsWith("bbox_")) { + for (cube in bone.cubes) { + val origin = cube.origin + val size = cube.size + val box = Box3() + box.expandByPoint(origin.copy().negateY()) + box.expandByPoint((origin + size).negateY()) + box.expandByScalar(cube.inflate + 0.025f) + boundingBoxes.add(box to bone.side) + } + continue // only for data purposes, do not render + } + val boneModel = makeBone(bone.name) + boneModel.pivotX = bone.pivot.x + boneModel.pivotY = -bone.pivot.y + boneModel.pivotZ = bone.pivot.z + boneModel.rotateAngleX = bone.rotation.x.toRadians() + boneModel.rotateAngleY = bone.rotation.y.toRadians() + boneModel.rotateAngleZ = bone.rotation.z.toRadians() + boneModel.mirror = bone.mirror + boneModel.side = bone.side + + for (cube in bone.cubes) { + val (x, y, z) = cube.origin.copy().negateY() + val (dx, dy, dz) = cube.size + val mirror = cube.mirror ?: bone.mirror + + val inflate = cube.inflate + extraInflate + val cubeModel = when (val uv = cube.uv) { + is ModelFile.Uvs.PerFace -> { + val uvData = CubeUvData( + uv.north.toFloatArray(), + uv.east.toFloatArray(), + uv.south.toFloatArray(), + uv.west.toFloatArray(), + uv.up.toFloatArray(), + uv.down.toFloatArray() + ) + Cube(boneModel, x, y - dy, z, dx, dy, dz, inflate, mirror, uvData) + } + is ModelFile.Uvs.Box -> { + val (u, v) = uv.uv + Cube(boneModel, u, v, x, y - dy, z, dx, dy, dz, inflate, mirror) + } + } + boneModel.cubeList.add(cubeModel) + } + + (boneByName[bone.parent] ?: rootBone).addChild(boneModel) + } + + fun Bone.setAffectsPoseParts() { + childModels.forEach { it.setAffectsPoseParts() } + + val affectedParts = mutableSetOf() + EnumPart.fromBoneName(boxName)?.let { affectedParts.add(it) } + childModels.forEach { affectedParts.addAll(it.affectsPoseParts) } + + affectsPose = affectedParts.isNotEmpty() + affectsPoseParts = affectedParts + } + rootBone.setAffectsPoseParts() + } + + private fun Float.toRadians() = (this / 180.0 * PI).toFloat() + + private fun ModelFile.UvFace?.toFloatArray(): FloatArray { + this ?: return floatArrayOf(0f, 0f, 0f, 0f) + val (u, v) = uv + val (du, dv) = size + return floatArrayOf(u, v, u + du, v + dv) + } + + companion object { + // Ascending priority -> higher inflate + private val EXTRA_INFLATE_GROUPS = listOf( + setOf( + CosmeticSlot.PANTS, + ), + setOf( + CosmeticSlot.TOP, + CosmeticSlot.HEAD, + CosmeticSlot.FACE, + CosmeticSlot.BACK, + ), + setOf( + CosmeticSlot.HAT, + CosmeticSlot.FULL_BODY, + ), + setOf( + CosmeticSlot.ACCESSORY, + CosmeticSlot.ARMS, + CosmeticSlot.SHOES, + ) + ).withIndex() + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleEffect.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleEffect.kt new file mode 100644 index 0000000..0883a27 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleEffect.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.model.backend.RenderBackend +import gg.essential.model.file.ParticleEffectComponents +import gg.essential.model.file.ParticlesFile + +class ParticleEffect( + val material: ParticlesFile.Material, + val components: ParticleEffectComponents, + val curves: Map, + val events: Map, + val texture: RenderBackend.Texture?, + /** All effects referenced by events of this effect. May contain more events than actually referenced. */ + val referencedEffects: Map, +) { + val renderPass = texture?.let { RenderPass(material, it) } + data class RenderPass(val material: ParticlesFile.Material, val texture: RenderBackend.Texture) +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleSystem.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleSystem.kt new file mode 100644 index 0000000..28151cf --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/ParticleSystem.kt @@ -0,0 +1,1094 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import dev.folomeev.kotgl.matrix.vectors.Vec2 +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import dev.folomeev.kotgl.matrix.vectors.dot +import dev.folomeev.kotgl.matrix.vectors.mutables.cross +import dev.folomeev.kotgl.matrix.vectors.mutables.crossSelf +import dev.folomeev.kotgl.matrix.vectors.mutables.div +import dev.folomeev.kotgl.matrix.vectors.mutables.minus +import dev.folomeev.kotgl.matrix.vectors.mutables.mutableVec2 +import dev.folomeev.kotgl.matrix.vectors.mutables.mutableVec3 +import dev.folomeev.kotgl.matrix.vectors.mutables.normalize +import dev.folomeev.kotgl.matrix.vectors.mutables.normalizeSelf +import dev.folomeev.kotgl.matrix.vectors.mutables.plus +import dev.folomeev.kotgl.matrix.vectors.mutables.plusScaled +import dev.folomeev.kotgl.matrix.vectors.mutables.plusScaledSelf +import dev.folomeev.kotgl.matrix.vectors.mutables.plusSelf +import dev.folomeev.kotgl.matrix.vectors.mutables.set +import dev.folomeev.kotgl.matrix.vectors.mutables.times +import dev.folomeev.kotgl.matrix.vectors.mutables.timesSelf +import dev.folomeev.kotgl.matrix.vectors.mutables.toMutable +import dev.folomeev.kotgl.matrix.vectors.sqrLength +import dev.folomeev.kotgl.matrix.vectors.vec2 +import dev.folomeev.kotgl.matrix.vectors.vec3 +import dev.folomeev.kotgl.matrix.vectors.vecUnitX +import dev.folomeev.kotgl.matrix.vectors.vecUnitY +import dev.folomeev.kotgl.matrix.vectors.vecUnitZ +import dev.folomeev.kotgl.matrix.vectors.vecZero +import gg.essential.model.collision.CollisionProvider +import gg.essential.model.file.ParticleEffectComponents +import gg.essential.model.file.ParticleEffectComponents.ParticleAppearanceBillboard.Direction.Custom +import gg.essential.model.file.ParticleEffectComponents.ParticleAppearanceBillboard.Direction.FromVelocity +import gg.essential.model.file.ParticleEffectComponents.ParticleAppearanceBillboard.FacingCameraMode.* +import gg.essential.model.file.ParticlesFile +import gg.essential.model.molang.MolangContext +import gg.essential.model.molang.MolangExpression +import gg.essential.model.molang.MolangQuery +import gg.essential.model.util.Color +import gg.essential.model.light.Light +import gg.essential.model.light.LightProvider +import gg.essential.model.molang.MolangQueryTime +import gg.essential.model.molang.Variables +import gg.essential.model.molang.VariablesMap +import gg.essential.model.util.Quaternion +import gg.essential.model.util.UMatrixStack +import gg.essential.model.util.UVertexConsumer +import gg.essential.model.util.rotateBy +import gg.essential.model.util.rotateSelfBy +import kotlin.math.PI +import kotlin.math.absoluteValue +import kotlin.math.sqrt +import kotlin.random.Random +import kotlin.random.nextInt + +/** + * A sub-system for managing and rendering particles. + * + * The two fundamental entities of this system are [Particle]s and [Emitter]s. + * + * Emitters are invisible and remain fixed at one position and rotation (relative to a [Locator]) where they emit + * particles and/or events (which can spawn other emitters, particles, sounds, etc.). + * + * Particles on the other hand will move around depending on their configuration and can be visible in the form of + * billboards. They too can emit events at their position. + * + * Every particles has a corresponding emitter that spawned it, even when it was spawned as a one-shot particle (in this + * case the emitter is just never active and immediately marked as dead). + * How an emitter and its corresponding particles behave is configured by their [ParticleEffect] (corresponds to one + * particles json file). Different emitters can have different configuration but all particles share the configuration + * of their respective emitter. + * + * For practical purposes, a particle system is sub-divided into separate [Universe]s each of which has a different + * source of time. This is so particles spawned by one cosmetic can play slightly faster so they can catch up with the + * remote client that spawned them, similar to how cosmetic animations slowly catch up to be in sync as well. + */ +class ParticleSystem( + private val random: Random, + private val collisionProvider: CollisionProvider, + private val lightProvider: LightProvider, +) { + private val universes = mutableMapOf() + + private val billboardRenderPasses = mutableMapOf>() + + private inner class Universe( + val timeSource: MolangQueryTime, + ) { + var lastUpdate: Float = timeSource.time + + val emitters = mutableListOf() + val particles = mutableListOf() + + fun addParticle(particle: Particle) { + particles.add(particle) + + val effect = particle.emitter.effect + val renderPass = effect.renderPass + if (renderPass != null) { + if (effect.components.particleAppearanceBillboard != null) { + billboardRenderPasses.getOrPut(renderPass, ::mutableSetOf).add(particle) + } + } + } + + fun removeParticleAt(index: Int): Particle { + val particle = particles.set(index, null) ?: throw IndexOutOfBoundsException() + + val effect = particle.emitter.effect + val renderPass = effect.renderPass + if (renderPass != null) { + if (effect.components.particleAppearanceBillboard != null) { + val renderPassSet = billboardRenderPasses.getValue(renderPass) + renderPassSet.remove(particle) + if (renderPassSet.isEmpty()) { + billboardRenderPasses.remove(renderPass) + } + } + } + + return particle + } + } + + fun spawn(event: ModelAnimationState.ParticleEvent) { + val universe = universes.getOrPut(event.timeSource) { Universe(event.timeSource) } + val emitter = Emitter( + this, + universe, + event.effect, + event.locator.position, + event.locator.rotation, + vec3(), + event.locator, + vec3(), + ) + event.preEffectScript?.eval(emitter.molang) + universe.emitters.add(emitter) + emitter.startLoop(0f) + } + + fun update() { + var cleanup = false + for (universe in universes.values) { + update(universe) + + if (universe.emitters.isEmpty() && universe.particles.isEmpty()) { + cleanup = true + } + } + if (cleanup) { + universes.values.removeAll { it.emitters.isEmpty() && it.particles.isEmpty() } + } + } + + private fun update(universe: Universe) { + val now = universe.timeSource.time + val dt = now - universe.lastUpdate + universe.lastUpdate = now + + val emitters = universe.emitters + val particles = universe.particles + + // Before all else, get the current last index for emitters and particles + // This allows us to differentiate which particles were already present before this update and which ones were + // only added during this update step. This is important because only the former should receive an `update` call + // for `dt`, the latter do not yet live that long and will receive an `update` call for the appropriate fraction + // of `dt` from whoever spawned them. + // When an emitter/particle expires, we also won't simply remove it from the list, as that would mess with + // the indices (and result in horrible performance if we need to shift a large array of data by one each time). + // Instead we set the entry to `null` and then compress the array only at the end of the update call. + val lastEmitterIndex = emitters.lastIndex + val lastParticleIndex = particles.lastIndex + + // How many expired ones there are currently present in the lists. + // If this becomes large enough, we'll re-compress the lists. + var expiredEmitters = 0 + var expiredParticles = 0 + + // Main update loops + // We iterate via indices because we explicitly allow modification (see comment at start of this method). + for (i in 0..lastEmitterIndex) { + val emitter = emitters[i] + if (emitter != null) { + if (emitter.update(dt)) { + continue + } + emitters[i] = null + } + expiredEmitters++ + } + for (i in 0..lastParticleIndex) { + val particle = particles[i] + if (particle != null) { + if (particle.update(dt)) { + continue + } + universe.removeParticleAt(i) + particle.emitter.activeParticles-- + } + expiredParticles++ + } + + // Finally, if there's enough holes in the lists, re-compress them in a single pass + if (expiredEmitters > 64 || expiredEmitters == emitters.size) { + emitters.removeAll { it == null } + } + if (expiredParticles > 64 || expiredParticles == particles.size) { + particles.removeAll { it == null } + } + } + + fun isEmpty(): Boolean = universes.isEmpty() + + fun hasAnythingToRender(): Boolean = + billboardRenderPasses.isNotEmpty() + + fun render( + matrixStack: UMatrixStack, + cameraPos: Vec3, + cameraRot: Quaternion, + particleVertexConsumerProvider: VertexConsumerProvider, + ) { + for ((renderPass, particles) in billboardRenderPasses.entries.sortedBy { it.key.material.needsSorting }) { + particleVertexConsumerProvider.provide(renderPass) { vertexConsumer -> + if (renderPass.material.needsSorting) { + for (particle in particles) { + particle.prepareBillboard(cameraPos, cameraRot) + + val billboardNormal = mutableVec3(0f, 0f, -1f).rotateSelfBy(particle.billboardRotation) + particle.distance = cameraPos.minus(particle.billboardPosition).dot(billboardNormal) + } + for (particle in particles.sortedByDescending { it.distance }) { + particle.renderBillboard(matrixStack, vertexConsumer) + } + } else { + for (particle in particles) { + particle.prepareBillboard(cameraPos, cameraRot) + particle.renderBillboard(matrixStack, vertexConsumer) + } + } + } + } + } + + private class Emitter( + val system: ParticleSystem, + val universe: Universe, + val effect: ParticleEffect, + /** Global position of the emitter. Updated each frame if alive and bound to a locator. */ + var position: Vec3, + /** Global rotation of the emitter. Updated each frame if alive and bound to a locator. */ + var rotation: Quaternion, + /** Global velocity of the emitter. Updated each frame if alive and bound to a locator. */ + var velocity: Vec3, + /** The locator which this emitter is bound to. */ + val locator: Locator?, + /** Offset of this emitter from the locator position in local space. */ + val locatorOffset: Vec3?, + ) { + private val components = effect.components + private val curveVariables = CurveVariables({ molang }, effect.curves) + private val variables = VariablesMap().fallbackBackTo(curveVariables) + val molang: MolangContext = MolangContext(MolangQuery.Empty, variables) + + init { + variables["entity_scale"] = 1f + components.emitterInitialization?.creationExpression?.eval(molang) + } + + var activeParticles = 0 + + private var firedCreationEvents = false + private var firedExpirationEvents = false + + private var age: Float by variables.getOrPut("emitter_age", 0f) + + private var activeTime: Float by variables.getOrPut("emitter_lifetime", 0f) + + private var sleepTime: Float = 0f + + /** No particles will be spawned unless [activeParticles] is smaller than this value. */ + private var maxParticles: Float = 0f + + /** No particle will be spawned until this reaches 0. */ + private var cooldownTime: Float = 0f + + /** The next timeline event that will be emitted during an [update] call once its time has come. */ + private var nextTimelineEvent: Map.Entry>? = null + + fun startLoop(timeSince: Float) { + for (i in 1..4) { + variables["emitter_random_$i"] = system.random.nextFloat() + } + + age = 0f + + activeTime = components.emitterLifetimeLooping?.activeTime?.eval(molang) + ?: components.emitterLifetimeOnce?.activeTime?.eval(molang) + ?: Float.POSITIVE_INFINITY + sleepTime = components.emitterLifetimeLooping?.sleepTime?.eval(molang) + ?: 0f + + repeat(components.emitterRateInstant?.numParticles?.eval(molang)?.toInt() ?: 0) { + emit(timeSince) + } + + maxParticles = components.emitterRateSteady?.maxParticles?.eval(molang) + ?: Float.POSITIVE_INFINITY + + cooldownTime = components.emitterRateSteady?.spawnRate?.eval(molang)?.let { 1 / it } + ?: Float.POSITIVE_INFINITY + + if (!firedCreationEvents) { + firedCreationEvents = true + fire(timeSince, components.emitterLifetimeEvents.creationEvents, null) + } + + nextTimelineEvent = components.emitterLifetimeEvents.timeline.lowestEntry() + } + + fun update(dt: Float): Boolean { + val alive = doUpdate(dt) + + if (!alive && !firedExpirationEvents) { + firedExpirationEvents = true + fire(0f, components.emitterLifetimeEvents.expirationEvents, null) + } + + return alive + } + + private fun doUpdate(dt: Float): Boolean { + age += dt + + curveVariables.update() + + components.emitterInitialization?.perUpdateExpression?.eval(molang) + + if (locator != null) { + position = locator.position + rotation = locator.rotation + velocity = locator.velocity + if (locatorOffset != null) { + position = position.plus(locatorOffset.rotateBy(rotation)) + } + + if (!locator.isValid) { + return false + } + } + + fireTimelineEvents() + + components.emitterLifetimeExpression?.let { config -> + if (config.expirationExpression.eval(molang) != 0f) { + return false + } + + if (config.activationExpression.eval(molang) == 0f) { + return true + } + } + + if (age > activeTime) { + if (components.emitterLifetimeOnce != null) { + return false + } + val timeSinceNewLoop = age - activeTime - sleepTime + if (timeSinceNewLoop < 0) { + return true + } + startLoop(timeSinceNewLoop) + } + + cooldownTime -= dt + while (cooldownTime < 0) { + if (activeParticles < maxParticles) { + val timeSinceEmit = -cooldownTime + age -= timeSinceEmit + emit(timeSinceEmit) + cooldownTime += components.emitterRateSteady?.spawnRate?.eval(molang)?.let { 1 / it } ?: Float.POSITIVE_INFINITY + age += timeSinceEmit + } else { + cooldownTime = 0f + } + } + + return true + } + + fun emit(dt: Float, inheritVelocity: Boolean = false) { + val localSpace = if (components.emitterLocalSpace?.position == true) locator else null + + val particle = Particle(this, localSpace) + + particle.emit(inheritVelocity) + + universe.addParticle(particle) + particle.emitter.activeParticles++ + + particle.update(dt) + } + + private fun fireTimelineEvents() { + while (true) { + val (time, events) = nextTimelineEvent ?: return + val timeSinceEvent = age - time + if (timeSinceEvent < 0) return + + fire(timeSinceEvent, events, null) + + nextTimelineEvent = components.emitterLifetimeEvents.timeline.higherEntry(time) + } + } + + fun fire(timeSince: Float, events: List, particle: Particle?) = + events.forEach { fire(timeSince, it, particle) } + + fun fire(timeSince: Float, eventName: String, particle: Particle?) { + val event = effect.events[eventName] ?: return + fire(timeSince, event, particle) + } + + fun fire(timeSince: Float, event: ParticlesFile.Event, particle: Particle?) { + event.sequence?.forEach { fire(timeSince, it, particle) } + + event.randomize?.let { options -> + val weights = options.sumOf { it.weight.toDouble() } + var choice = system.random.nextFloat() * weights + for (option in options) { + choice -= option.weight + if (choice <= 0) { + fire(timeSince, option.value, particle) + break + } + } + } + + event.expression?.let { expr -> + age -= timeSince + expr.eval(molang) + age += timeSince + } + + event.particle?.let { config -> + val targetEffect = effect.referencedEffects[config.effect] ?: return@let + // TODO: docs say about "particle" that we should be "creating the emitter if it doesn't already exist" + // but how are we supposed to determine whether an emitter "at the event location" already + // exists in the first place? just going to create a new one each time for now + val targetEmitter = if (config.type.isBound && locator != null) { + Emitter( + system, + universe, + targetEffect, + particle?.globalPosition ?: position, + rotation, + particle?.globalVelocity ?: velocity, + locator, + particle?.globalPosition + ?.minus(locator.position)?.rotateSelfBy(locator.rotation.invert()) + ?: locatorOffset, + ) + } else { + Emitter( + system, + universe, + targetEffect, + particle?.globalPosition ?: position, + Quaternion.Identity, + particle?.globalVelocity ?: velocity, + null, + null, + ) + } + config.preEffectExpression.eval(targetEmitter.molang) + if (config.type.isParticle) { + targetEmitter.emit(timeSince, config.type.inheritVelocity) + } else { + universe.emitters.add(targetEmitter) + targetEmitter.startLoop(timeSince) + targetEmitter.update(timeSince) + } + } + + event.sound?.let { config -> + // TODO sound, eventually + } + } + } + + private class Particle( + val emitter: Emitter, + /** This is the locator describing the space in which this particle is simulated if it is not global. */ + val localSpace: Locator?, + ) { + private val components = emitter.effect.components + val curveVariables = CurveVariables({ molang }, emitter.effect.curves) + private val variables = VariablesMap() + .fallbackBackTo(curveVariables) + .fallbackBackTo(emitter.molang.variables) + private val molang: MolangContext = MolangContext(MolangQuery.Empty, variables) + + private var firedCreationEvents = false + private var firedExpirationEvents = false + private var nextTimelineEvent: Map.Entry>? = null + + private var age: Float by variables.getOrPut("particle_age", 0f) + private var lifetime: Float by variables.getOrPut("particle_lifetime", 0f) + + init { + for (i in 1..4) { + variables["particle_random_$i"] = emitter.system.random.nextFloat() + } + + age = 0f + lifetime = components.particleLifetimeExpression?.maxLifetime?.eval(molang) ?: 0f + nextTimelineEvent = components.particleLifetimeEvents.timeline.lowestEntry() + } + + /** Position of this particle. In local space if [localSpace] is given, otherwise in global space. */ + val position = mutableVec3() + /** Velocity of this particle. In local space if [localSpace] is given, otherwise in global space. */ + val velocity = mutableVec3() + /** Direction of this particle. In local space if [localSpace] is given, otherwise in global space. */ + val direction = mutableVec3() + + /** Position of this particle in global space. */ + val globalPosition: Vec3 + get() = if (localSpace != null) position.rotateBy(localSpace.rotation).plusSelf(localSpace.position) else position + /** Velocity of this particle in global space. */ + val globalVelocity: Vec3 + get() = if (localSpace != null) velocity.rotateBy(localSpace.rotation) else velocity + + var rotationAngle = components.particleInitialSpin?.rotation?.eval(molang) ?: 0f + var rotationRate = components.particleInitialSpin?.rotationRate?.eval(molang) ?: 0f + + /** Stores the global position of the billboard (if any) of this particle. Valid only during rendering.*/ + var billboardPosition = vec3() + /** Stores the global rotation of the billboard (if any) of this particle. Valid only during rendering. */ + var billboardRotation = Quaternion.Identity + /** Temporary value used for sorting because Kotlin doesn't seem to have a `sort_by_cached_key`. */ + var distance: Float = 0f + + fun emit(inheritVelocity: Boolean) { + var pos: Vec3 = vecZero() + var dir: Vec3 = vecZero() + + fun ParticleEffectComponents.Direction.computeFor(point: Vec3): Vec3 = + when (this) { + ParticleEffectComponents.Direction.Inwards -> point.times(-1f) + ParticleEffectComponents.Direction.Outwards -> point + is ParticleEffectComponents.Direction.Custom -> vec.eval(molang) + } + + val random = emitter.system.random + + components.emitterShapePoint?.let { config -> + pos = config.offset.eval(molang) + dir = config.direction.eval(molang) + } + + components.emitterShapeBox?.let { config -> + val point = mutableVec3( + (random.nextFloat() - 0.5f) * 2f, + (random.nextFloat() - 0.5f) * 2f, + (random.nextFloat() - 0.5f) * 2f, + ) + + if (config.surfaceOnly) { + val side = random.nextInt(0..5) + val value = if (side > 2) 1f else -1f + when (side % 3) { + 0 -> point.x = value + 1 -> point.y = value + 2 -> point.z = value + } + } + + point.timesSelf(config.halfDimensions.eval(molang)) + pos = config.offset.eval(molang).plus(point) + dir = config.direction.computeFor(point) + } + + components.emitterShapeSphere?.let { config -> + val vec = mutableVec3() + do { + vec.set( + (random.nextFloat() - 0.5f) * 2f, + (random.nextFloat() - 0.5f) * 2f, + (random.nextFloat() - 0.5f) * 2f, + ) + } while (vec.sqrLength().let { it > 1 || it == 0f }) + + if (config.surfaceOnly) { + vec.normalizeSelf() + } + + vec.timesSelf(config.radius.eval(molang)) + pos = config.offset.eval(molang).plus(vec) + dir = config.direction.computeFor(vec) + } + + components.emitterShapeDisc?.let { config -> + val radius = config.radius.eval(molang) + val normal = config.planeNormal.eval(molang).normalize() + + // To get a uniformly distributed random point on the disc, we first compute an arbitrary unit vector + // in the disc plane, then rotate that around the normal by a random amount and finally scale it by a + // random amount. + + // We start with an arbitrary unit vector + val vec = mutableVec3(1f, 0f, 0f) + // just needs to be linearly independent from the normal; if it's not, choose a different one + if (vec.dot(normal).absoluteValue > 0.9) { + vec.set(0f, 1f, 0f) + } + // then we can get an arbitrary unit vector in the disc plane via a simple cross product + vec.crossSelf(normal).normalizeSelf() + // now rotate around the normal to get a random unit vector in the disc plane + vec.rotateSelfBy(Quaternion.fromAxisAngle(normal, random.nextFloat() * 2 * PI.toFloat())) + // finally we just need to scale it by the radius and (optionally) uniformly distribute it across the + // plane instead of just its rim + vec.timesSelf(radius * if (config.surfaceOnly) 1f else sqrt(random.nextFloat())) + + pos = config.offset.eval(molang).plus(vec) + dir = config.direction.computeFor(vec) + } + + if (components.emitterLocalSpace?.rotation != true) { + pos = pos.rotateBy(emitter.rotation) + dir = dir.rotateBy(emitter.rotation) + } + + if (localSpace == null) { + pos = pos.plus(emitter.position) + } else if (emitter.locatorOffset != null) { + pos = pos.plus(emitter.locatorOffset) + } + + position.set(pos) + direction.set(dir).normalizeSelf() + velocity.set(direction).timesSelf(components.particleInitialSpeed.eval(molang)) + + if (inheritVelocity || components.emitterLocalSpace?.velocity == true) { + velocity.plusSelf(emitter.velocity) + } + } + + fun update(dt: Float): Boolean { + if (!firedCreationEvents) { + firedCreationEvents = true + emitter.fire(dt, components.particleLifetimeEvents.creationEvents, this) + } + + val alive = doUpdate(dt) + + if (!alive && !firedExpirationEvents) { + firedExpirationEvents = true + emitter.fire(0f, components.particleLifetimeEvents.expirationEvents, this) + } + + return alive + } + + private fun doUpdate(dt: Float): Boolean { + age += dt + + curveVariables.update() + + fireTimelineEvents() + + if (age >= lifetime) { + return false + } + + components.particleLifetimeExpression?.let { config -> + if (config.expirationExpression.eval(molang) != 0f) { + return false + } + } + + components.particleMotionParametric?.let { config -> + position.set(config.relativePosition.eval(molang)) + rotationAngle = config.rotation.eval(molang) + if (config.direction != null) { + direction.set(config.direction.eval(molang)) + velocity.set(vecZero()) + } + } + + components.particleMotionDynamic?.let { config -> + val linearAcceleration = config.linearAcceleration.eval(molang).toMutable() + linearAcceleration.plusScaledSelf(-config.linearDragCoefficient.eval(molang), velocity) + if (!move(dt, linearAcceleration)) { + return false + } + + var rotAcceleration = config.rotationAcceleration.eval(molang) + rotAcceleration -= rotationRate * config.rotationDragCoefficient.eval(molang) + rotAcceleration *= dt + var deltaRotation = rotationRate + rotationRate += rotAcceleration + deltaRotation += rotationRate + deltaRotation *= 0.5f * dt + rotationAngle += deltaRotation + } + + components.particleAppearanceBillboard?.let { config -> + if (config.direction is FromVelocity) { + val lengthSqr = velocity.sqrLength() + if (lengthSqr > config.direction.minSpeedThresholdSqr) { + direction.set(velocity) + } + } + } + + return true + } + + private fun fireTimelineEvents() { + while (true) { + val (time, events) = nextTimelineEvent ?: return + val timeSinceEvent = age - time + if (timeSinceEvent < 0) return + + emitter.fire(timeSinceEvent, events, this) + + nextTimelineEvent = components.particleLifetimeEvents.timeline.higherEntry(time) + } + } + + /** + * Moves the particle assuming constant [acceleration] over [dt] time. + * May be invoked recursively in case of collisions. If so, [iteration] will be incremented by one each time. + * Applies contact friction if [sliding] is `true` (it is for one of the recursive calls). + */ + private fun move(dt: Float, acceleration: Vec3, iteration: Int = 0, sliding: Boolean = false): Boolean { + val offset = mutableVec3(velocity) + offset.plusScaledSelf(0.5f * dt, acceleration) + offset.timesSelf(dt) + + val config = components.particleMotionCollision + if (config == null) { + position.plusSelf(offset) + velocity.plusScaledSelf(dt, acceleration) + return true + } + + val collision = emitter.system.collisionProvider.query(position, config.collisionRadius, offset) + if (collision == null) { + position.plusSelf(offset) + velocity.plusScaledSelf(dt, acceleration) + if (sliding) { + // TODO unclear how this value should be input into this calculation, bedrocks docs give its unit as + // blocks/sec but that's a speed.. idk what to do with that. I would have expected an + // acceleration (blocks/sec/sec) or a per-collision value (but then how long is a collision? + // or when does it turn from zero length to a prolonged contact?). + // For now I'm going to implement it as an acceleration applied for the duration the particle + // is `sliding` (that is, it bounces of the same wall more than once within dt). + val speedSqr = velocity.sqrLength() + if (speedSqr > 0.0000001f) { + val orgSpeed = sqrt(speedSqr) + val modifiedSpeed = (orgSpeed - config.collisionDrag * dt).coerceAtLeast(0f) + if (modifiedSpeed > 0.0001f) { + velocity.timesSelf(modifiedSpeed / orgSpeed) + } else { + velocity.set(vecZero()) + } + } else { + velocity.set(vecZero()) + } + } + return true + } + + val (maxOffset, surfaceNormal) = collision + + // If this is the third wall we've hit during this time-step, it's about time to give up and settle + // with whatever safe position we've got, otherwise we could be doing this all day. + // Same if the particle will die on contact, we'll want the death position correct and no more movement + // afterwards. + if (iteration >= 3 || config.expireOnContact) { + position.plusSelf(maxOffset) + velocity.plusScaledSelf(dt, acceleration) + return !config.expireOnContact + } + + // Estimate time until we hit the surface + // This is an estimation because it assumes constant velocity (when really we have linear velocity) but + // collision detection itself also assumes a linear path, so we can't do much better anyway. + // Note: the sqrt term is equivalent to `len(maxOffset) / len(offset)` but saves one sqrt invocation + val preDt = sqrt(maxOffset.sqrLength() / offset.sqrLength()).coerceIn(0f, 1f) * dt + + // Compute state right before and right after the collision + val velocityBeforeHit = velocity.plusScaled(preDt, acceleration) + val velocityAfterHit = reflect(velocityBeforeHit, surfaceNormal) + velocityAfterHit.plusScaledSelf( + (config.coefficientOfRestitution - 1) * velocityAfterHit.dot(surfaceNormal), + surfaceNormal + ) + val positionAtHit = position.plus(maxOffset) + + position.set(positionAtHit) + velocity.set(velocityAfterHit) + + // Continue simulation after the collision + val postDt = dt - preDt + val postOffset = mutableVec3(velocityAfterHit) + postOffset.plusScaledSelf(0.5f * postDt, acceleration) + postOffset.timesSelf(postDt) + val positionPostBounce = positionAtHit.plus(postOffset) + + // Fire collision events (if any) + if (config.events.isNotEmpty()) { + // Docs do not specify which speed this should be. Simply using the before/after velocity results in an + // enormous amount of events if the particle is gliding with sufficient speed which doesn't seem + // helpful. + // So instead we'll choose the relative speed with respect to the obstacle we hit, so more or less the + // force of the impact, i.e. the velocity projected onto the obstacle's normal. + val sqrSpeed = -velocityBeforeHit.dot(surfaceNormal) + config.events.forEach { eventConfig -> + if (sqrSpeed >= eventConfig.minSpeed * eventConfig.minSpeed) { + emitter.fire(postDt, eventConfig.event, this) + } + } + } + + // Check if we're going to make it away from the surface, otherwise we're just going to bounce off of it + // again and again, potentially infinitely often, and for that we better treat it as a slide than a series + // of bounces. + if (positionPostBounce.dot(surfaceNormal) > positionAtHit.dot(surfaceNormal)) { + // We will make it await from this surface, we could still hit another surface though, enter recursion + return move(postDt, acceleration, iteration + 1, sliding) + } + + // We will likely hit the same surface again within the same time step, meaning our bounces at this point + // are rather tiny, so we'll assume them to be negligible and instead simulate a slide along the surface. + val accelerationInPlane = acceleration.plusScaled(-acceleration.dot(surfaceNormal), surfaceNormal) + return move(postDt, accelerationInPlane, iteration + 1, true) + } + + /** + * Updates the billboard rendering related fields of this particle for the current frame. + * + * @throws UnsupportedOperationException if the particle does not have a billboard component + */ + fun prepareBillboard(cameraPos: Vec3, cameraRot: Quaternion) { + val appearance = components.particleAppearanceBillboard ?: throw UnsupportedOperationException() + val position = globalPosition + + fun computeDirection(): Vec3 { + val localDirection = when (val config = appearance.direction) { + is FromVelocity -> direction + is Custom -> config.direction.eval(molang) + } + return if (localSpace != null) { + localDirection.rotateBy(localSpace.rotation) + } else { + localDirection + } + } + + val localSpaceRotation = localSpace?.rotation ?: Quaternion.Identity + + var rot = when (appearance.facingCameraMode) { + RotateXYZ -> cameraRot.opposite() + RotateY -> cameraRot.opposite().projectAroundAxis(vecUnitY()) + LookAtXYZ -> Quaternion.fromLookAt(cameraPos.minus(position), vecUnitY()) + LookAtY -> Quaternion.fromLookAt(cameraPos.minus(position).apply { y = 0f }, vecUnitY()) + LookAtDirection -> { + val direction = computeDirection() + val target = cameraPos.minus(position).apply { plusScaledSelf(-this.dot(direction), direction) } + Quaternion.fromLookAt(target, direction.cross(target).normalizeSelf()) + } + DirectionX -> Quaternion.fromLookAt(computeDirection(), vecUnitY().rotateBy(localSpaceRotation)) * + Quaternion.fromAxisAngle(vecUnitY(), -PI.toFloat() / 2) + // Note: docs say the unrotated x axis should point upwards, but wintersky implements it such that the + // z axis / face points upwards, which makes more sense so I'll go with that + DirectionY -> Quaternion.fromLookAt(computeDirection(), vecUnitY().rotateBy(localSpaceRotation)) * + Quaternion.fromAxisAngle(vecUnitX(), -PI.toFloat() / 2) * Quaternion.Y180 + DirectionZ -> Quaternion.fromLookAt(computeDirection(), vecUnitY().rotateBy(localSpaceRotation)) + EmitterTransformXY -> (localSpace?.rotation ?: Quaternion.Identity) * Quaternion.Y180 + EmitterTransformXZ -> (localSpace?.rotation ?: Quaternion.Identity) * Quaternion.Y180 * Quaternion.fromAxisAngle(vecUnitX(), PI.toFloat() / 2) + EmitterTransformYZ -> (localSpace?.rotation ?: Quaternion.Identity) * Quaternion.fromAxisAngle(vecUnitY(), -PI.toFloat() / 2) + } + + if (rotationAngle != 0f) { + rot *= Quaternion.fromAxisAngle(vecUnitZ(), -rotationAngle / 180 * PI.toFloat()) + } + + billboardPosition = position + billboardRotation = rot + } + + /** + * Renders a billboard for this particle. + * Before this method may be called, [prepareBillboard] must be called each frame. + * + * @throws UnsupportedOperationException if the particle does not have a billboard component + */ + fun renderBillboard(matrixStack: UMatrixStack, vertexConsumer: UVertexConsumer) { + val appearance = components.particleAppearanceBillboard ?: throw UnsupportedOperationException() + + components.particleInitialization?.perRenderExpression?.eval(molang) + + val position = billboardPosition + val rotation = billboardRotation + val (sizeX, sizeY) = appearance.size.eval(molang) + val textureSize = vec2(appearance.uv.textureWidth.toFloat(), appearance.uv.textureHeight.toFloat()) + val color = components.particleAppearanceTinting?.color?.eval(molang)?.let(Color::fromVec) ?: Color.WHITE + val light = if (components.particleAppearanceLighting != null) { + emitter.system.lightProvider.query(position) + } else { + Light.MAX_VALUE + } + + var minUV: Vec2 + var maxUV: Vec2 + + val flipbook = appearance.uv.flipbook + if (flipbook != null) { + val base = flipbook.base.eval(molang) + val size = flipbook.size.toVec2() + val step = flipbook.step.toVec2() + val maxFrame = flipbook.maxFrame.eval(molang).toInt() + val timePerFrame = if (flipbook.stretchToLifetime) { + lifetime / maxFrame + } else { + 1 / flipbook.framePerSecond + } + val frame = (age / timePerFrame).toInt().let { frame -> + if (flipbook.loop) { + frame % maxFrame + } else { + frame.coerceAtMost(maxFrame) + } + } + minUV = base.plusScaled(frame.toFloat(), step) + maxUV = minUV.plus(size) + } else { + val base = appearance.uv.uv?.eval(molang) ?: vecZero() + val size = appearance.uv.uvSize?.eval(molang) ?: textureSize + minUV = base + maxUV = minUV.plus(size) + } + + minUV = minUV.div(textureSize) + maxUV = maxUV.div(textureSize) + + fun emitPoint(x: Float, y: Float, u: Float, v: Float) { + val pos = mutableVec3(x, y, 0f) + pos.rotateSelfBy(rotation) + vertexConsumer + .pos(matrixStack, position.x + pos.x.toDouble(), position.y + pos.y.toDouble(), position.z + pos.z.toDouble()) + .tex(u.toDouble(), v.toDouble()) + .color(color) + .light(light) + .endVertex() + } + emitPoint(-sizeX, -sizeY, maxUV.x, maxUV.y) + emitPoint(-sizeX, +sizeY, maxUV.x, minUV.y) + emitPoint(+sizeX, +sizeY, minUV.x, minUV.y) + emitPoint(+sizeX, -sizeY, minUV.x, maxUV.y) + } + } + + interface Locator { + val isValid: Boolean + val position: Vec3 + val rotation: Quaternion + val velocity: Vec3 + + object Zero : Locator { + override val isValid: Boolean + get() = true + override val position: Vec3 + get() = vecZero() + override val rotation: Quaternion + get() = Quaternion.Identity + override val velocity: Vec3 + get() = vecZero() + } + } + + interface VertexConsumerProvider { + fun provide(renderPass: ParticleEffect.RenderPass, block: (UVertexConsumer) -> Unit) + } +} + +/** Reflects [vec] about [norm]. `vec - 2 * (vec * norm) * norm` */ +private fun reflect(vec: Vec3, norm: Vec3) = + vec.plusScaled(-2 * vec.dot(norm), norm) + +private fun Pair.toVec2() = mutableVec2(first, second) + +private fun Pair.eval(context: MolangContext) = + mutableVec2(first.eval(context), second.eval(context)) + +private class CurveVariables( + private val context: () -> MolangContext, + private val curves: Map, +) : Variables { + private var frame = 0 + + private val variables = mutableMapOf() + + fun update() { + frame++ + } + + override fun getOrNull(name: String): Variables.Variable? = + variables.getOrPut(name) { + curves["variable.$name"]?.let { Variable(it) } + } + + override fun getOrPut(name: String, initialValue: Float): Variables.Variable = + getOrNull(name) ?: throw UnsupportedOperationException("$this does not support unknown variables") + + private inner class Variable(val curve: ParticlesFile.Curve) : Variables.Variable { + private var cachedFrame = -1 + private var cachedValue: Float = 0f + + override fun get(): Float { + if (cachedFrame == frame) { + return cachedValue + } + val value = curve.eval(context()) + cachedFrame = frame + cachedValue = value + return value + } + override fun set(value: Float) = Unit + } +} + +private fun ParticlesFile.Curve.eval(context: MolangContext): Float { + val range = range.eval(context) + val input = input.eval(context) / range + + return when (type) { + ParticlesFile.Curve.Type.Linear -> { + val position = input * nodes.lastIndex + val index = position.toInt() + when { + index < 0 -> nodes.first().eval(context) + index >= nodes.lastIndex -> nodes.last().eval(context) + else -> { + val alpha = position - index + nodes[index].eval(context).lerp(nodes[index + 1].eval(context), alpha) + } + } + } + ParticlesFile.Curve.Type.Bezier -> { + bezier( + input, + nodes[0].eval(context), + nodes[1].eval(context), + nodes[2].eval(context), + nodes[3].eval(context), + ) + } + ParticlesFile.Curve.Type.CatmullRom -> { + val position = 1 + input * (nodes.lastIndex - 2) + val index = position.toInt() + when { + index < 1 -> nodes[1].eval(context) + index >= nodes.lastIndex - 1 -> nodes[nodes.lastIndex - 1].eval(context) + else -> { + val alpha = position - index + catmullRom( + alpha, + nodes[index - 1].eval(context), + nodes[index ].eval(context), + nodes[index + 1].eval(context), + nodes[index + 2].eval(context), + ) + } + } + } + ParticlesFile.Curve.Type.BezierChain -> 0f // TODO requires more complex parsing, and we probably don't need it + } +} + diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/PositionTexVertex.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/PositionTexVertex.kt new file mode 100644 index 0000000..4eaaecc --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/PositionTexVertex.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import kotlin.jvm.JvmField + +// TODO clean up +data class PositionTexVertex( + @JvmField + var vector3: Vector3, + var texturePositionX: Float, + @JvmField + var texturePositionY: Float, +) { + + constructor(x: Float, y: Float, z: Float, u: Float, v: Float) : this(Vector3(x, y, z), u, v) + + constructor( + textureVertex: PositionTexVertex, + texturePositionXIn: Float, + texturePositionYIn: Float + ) : this(textureVertex.vector3, texturePositionXIn, texturePositionYIn) + + fun setTexturePosition(u: Float, v: Float): PositionTexVertex { + return PositionTexVertex(this, u, v) + } + + fun copy(): PositionTexVertex { + return PositionTexVertex(vector3.clone(), texturePositionX, texturePositionY) + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/RenderMetadata.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/RenderMetadata.kt new file mode 100644 index 0000000..b4f058b --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/RenderMetadata.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.model.backend.PlayerPose +import gg.essential.model.backend.RenderBackend + +data class RenderMetadata( + val pose: PlayerPose?, + val skin: RenderBackend.Texture, + val light: Int, + val scale: Float, + val side: Side?, + val hiddenBones: Set, + val positionAdjustment: Vector3, + val parts: Set?, +) \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/Side.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/Side.kt new file mode 100644 index 0000000..c23bbc3 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/Side.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.serialization.SnakeAsUpperCaseSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/* + The enum declaration order is in the order in which the sides are determined to be default. + The reasoning behind the order is that if we do have all 4 sides, we probably want "FRONT" as the default. + If we do not have "FRONT", we probably want one of the sides, which keep "LEFT" as a default from before. + If nothing else, we use "BACK". +*/ +@Serializable +enum class Side(val displayName: String) { + @SerialName("front") + FRONT("Front"), + @SerialName("left") + LEFT("Left"), + @SerialName("right") + RIGHT("Right"), + @SerialName("back") + BACK("Back"), + ; + + object UpperCase : SnakeAsUpperCaseSerializer(Side.serializer()) + + companion object { + + /* + The mod technically supports all 4 sides to be present, but in most cases sides will be left/right or front/back only. + That is why we cannot have a single default side, we must pick the default based on the sides available. + The order in which a default side is picked is the order of declaration on the sides in the enum, see the comment above. + */ + @JvmStatic + fun getDefaultSideOrNull(availableSides: Set): Side? { + if (availableSides.isEmpty()) return null + for (side in values()) { + if (availableSides.contains(side)) return side + } + return null // Impossible to reach, but kotlin doesn't know that + } + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/SoundCategory.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/SoundCategory.kt new file mode 100644 index 0000000..ad46135 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/SoundCategory.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class SoundCategory { + @SerialName("music") + MUSIC, + @SerialName("record") + RECORD, + @SerialName("weather") + WEATHER, + @SerialName("block") + BLOCK, + @SerialName("hostile") + HOSTILE, + @SerialName("neutral") + NEUTRAL, + @SerialName("player") + PLAYER, + @SerialName("ambient") + AMBIENT, +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/SoundEffect.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/SoundEffect.kt new file mode 100644 index 0000000..f51be93 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/SoundEffect.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import gg.essential.mod.EssentialAsset +import kotlin.random.Random + +class SoundEffect( + val name: String, + val category: SoundCategory, + // Note: Min distance is not currently implemented. Minecraft uses a fixed 0 for all sounds. + val minDistance: Float = 0f, + val maxDistance: Float = 16f, + val sounds: List, +) { + class Entry( + val asset: EssentialAsset, + val stream: Boolean = false, + val volume: Float = 1f, + val pitch: Float = 1f, + val directional: Boolean = true, + val weight: Int = 1, + ) + + fun randomEntry(): Entry? { + val total = sounds.sumOf { it.weight } + if (total <= 0) return null + + var i = Random.nextInt(total) + return sounds.first { i -= it.weight; i <= 0 } + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/Vector3.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/Vector3.kt new file mode 100644 index 0000000..1e4888f --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/Vector3.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model + +import kotlin.jvm.JvmField +import kotlin.math.* + +//Taken from https://github.com/markaren/three.kt +data class Vector3( + @JvmField + var x: Float, + @JvmField + var y: Float, + @JvmField + var z: Float +) { + + + constructor() : this(0f, 0f, 0f) + + constructor(x: Number, y: Number, z: Number) : this(x.toFloat(), y.toFloat(), z.toFloat()) + + /** + * Sets value of Vector3 vector. + */ + fun set(x: Number, y: Number, z: Number): Vector3 { + this.x = x.toFloat() + this.y = y.toFloat() + this.z = z.toFloat() + return this + } + + /** + * Sets all values of Vector3 vector. + */ + fun setScalar(s: Number): Vector3 { + return set(s, s, s) + } + + operator fun set(index: Int, value: Float): Vector3 { + when (index) { + 0 -> x = value + 1 -> y = value + 2 -> z = value + else -> throw IndexOutOfBoundsException() + } + return this + } + + operator fun get(index: Int): Float { + return when (index) { + 0 -> x + 1 -> y + 2 -> z + else -> throw IndexOutOfBoundsException() + } + } + + /** + * Clones Vector3 vector. + */ + fun clone() = copy() + + /** + * Copies value of v to Vector3 vector. + */ + fun copy(v: Vector3): Vector3 { + return set(v.x, v.y, v.z) + } + + /** + * Adds v to Vector3 vector. + */ + fun add(v: Vector3): Vector3 { + this.x += v.x + this.y += v.y + this.z += v.z + + return this + } + + fun addScalar(s: Float): Vector3 { + this.x += s + this.y += s + this.z += s + + return this + } + + /** + * Sets Vector3 vector to a + b. + */ + fun addVectors(a: Vector3, b: Vector3): Vector3 { + this.x = a.x + b.x + this.y = a.y + b.y + this.z = a.z + b.z + + return this + } + + /** + * Subtracts v from Vector3 vector. + */ + fun sub(v: Vector3): Vector3 { + this.x -= v.x + this.y -= v.y + this.z -= v.z + + return this + } + + /** + * Sets Vector3 vector to a - b. + */ + fun subVectors(a: Vector3, b: Vector3): Vector3 { + this.x = a.x - b.x + this.y = a.y - b.y + this.z = a.z - b.z + + return this + } + + fun multiply(v: Vector3): Vector3 { + this.x *= v.x + this.y *= v.y + this.z *= v.z + + return this + } + + /** + * Multiplies Vector3 vector by scalar s. + */ + fun multiplyScalar(s: Float): Vector3 { + this.x *= s + this.y *= s + this.z *= s + + return this + } + + + /** + * Divides Vector3 vector by scalar s. + * Set vector to ( 0, 0, 0 ) if s == 0. + */ + fun divideScalar(s: Float): Vector3 { + return this.multiplyScalar(1f / s) + } + + fun min(v: Vector3): Vector3 { + this.x = kotlin.math.min(this.x, v.x) + this.y = kotlin.math.min(this.y, v.y) + this.z = kotlin.math.min(this.z, v.z) + + return this + } + + fun max(v: Vector3): Vector3 { + this.x = kotlin.math.max(this.x, v.x) + this.y = kotlin.math.max(this.y, v.y) + this.z = kotlin.math.max(this.z, v.z) + + return this + } + + fun floor(): Vector3 { + this.x = kotlin.math.floor(this.x) + this.y = kotlin.math.floor(this.y) + this.z = kotlin.math.floor(this.z) + + return this + } + + fun ceil(): Vector3 { + this.x = kotlin.math.ceil(this.x) + this.y = kotlin.math.ceil(this.y) + this.z = kotlin.math.ceil(this.z) + + return this + } + + fun round(): Vector3 { + this.x = this.x.roundToInt().toFloat() + this.y = this.y.roundToInt().toFloat() + this.z = this.z.roundToInt().toFloat() + + return this + } + + fun negateY(): Vector3 { + this.y = -this.y + + return this + } + + /** + * Computes dot product of Vector3 vector and v. + */ + fun dot(v: Vector3): Float { + return this.x * v.x + this.y * v.y + this.z * v.z + } + + /** + * Computes length of Vector3 vector. + */ + fun length(): Float { + return sqrt(this.x * this.x + this.y * this.y + this.z * this.z) + } + + + + /** + * Normalizes Vector3 vector. + */ + fun normalize(): Vector3 { + var length = length() + if (length.isNaN()) length = 1.toFloat() + return this.divideScalar(length) + } + + /** + * Normalizes Vector3 vector and multiplies it by l. + */ + fun setLength(length: Float): Vector3 { + return this.normalize().multiplyScalar(length) + } + + fun lerp(v: Vector3, alpha: Float): Vector3 { + this.x += (v.x - this.x) * alpha + this.y += (v.y - this.y) * alpha + this.z += (v.z - this.z) * alpha + + return this + } + + /** + * Sets Vector3 vector to cross product of itself and v. + */ + fun cross(v: Vector3): Vector3 { + return this.crossVectors(this, v) + } + + /** + * Sets Vector3 vector to cross product of a and b. + */ + fun crossVectors(a: Vector3, b: Vector3): Vector3 { + val ax = a.x + val ay = a.y + val az = a.z + val bx = b.x + val by = b.y + val bz = b.z + + this.x = ay * bz - az * by + this.y = az * bx - ax * bz + this.z = ax * by - ay * bx + + return this + } + + fun reflect(normal: Vector3): Vector3 { + val v1 = Vector3() + return this.sub(v1.copy(normal).multiplyScalar(2 * this.dot(normal))) + } + + + operator fun plus(b: Vector3): Vector3 { + return copy().add(b) + } + + operator fun minus(b: Vector3): Vector3 { + return copy().sub(b) + } + + + companion object { + + @JvmField + val X = Vector3(1f, 0f, 0f) + @JvmField + val Y = Vector3(0f, 1f, 0f) + @JvmField + val Z = Vector3(0f, 0f, 1f) + + } + +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/backend/PlayerPose.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/backend/PlayerPose.kt new file mode 100644 index 0000000..97c4b4c --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/backend/PlayerPose.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.backend + +import dev.folomeev.kotgl.matrix.matrices.Mat4 +import gg.essential.model.EnumPart +import kotlin.math.PI + +data class PlayerPose( + val head: Part, + val body: Part, + val rightArm: Part, + val leftArm: Part, + val rightLeg: Part, + val leftLeg: Part, + val rightShoulderEntity: Part, + val leftShoulderEntity: Part, + val rightWing: Part, + val leftWing: Part, + val cape: Part, + val child: Boolean, +) : Map { + + override val entries: Set> + get() = keys.mapTo(mutableSetOf()) { MapEntry(it, get(it)) } + override val keys: Set + get() = EnumPart.values().toSet() + override val size: Int + get() = EnumPart.values().size + override val values: Collection + get() = entries.map { it.value } + + override fun containsKey(key: EnumPart): Boolean = true + + override fun containsValue(value: Part): Boolean = value in values + + override fun get(key: EnumPart): Part = when(key) { + EnumPart.HEAD -> head + EnumPart.BODY -> body + EnumPart.RIGHT_ARM -> rightArm + EnumPart.LEFT_ARM -> leftArm + EnumPart.RIGHT_LEG -> rightLeg + EnumPart.LEFT_LEG -> leftLeg + EnumPart.RIGHT_SHOULDER_ENTITY -> rightShoulderEntity + EnumPart.LEFT_SHOULDER_ENTITY -> leftShoulderEntity + EnumPart.RIGHT_WING -> rightWing + EnumPart.LEFT_WING -> leftWing + EnumPart.CAPE -> cape + } + + override fun isEmpty(): Boolean = false + + data class Part( + val pivotX: Float = 0f, + val pivotY: Float = 0f, + val pivotZ: Float = 0f, + val rotateAngleX: Float = 0f, + val rotateAngleY: Float = 0f, + val rotateAngleZ: Float = 0f, + val extra: Mat4? = null, + ) + + private data class MapEntry(override val key: K, override val value: V) : Map.Entry + + companion object { + fun fromMap(map: Map, child: Boolean) = + PlayerPose( + head = map.getValue(EnumPart.HEAD), + body = map.getValue(EnumPart.BODY), + rightArm = map.getValue(EnumPart.RIGHT_ARM), + leftArm = map.getValue(EnumPart.LEFT_ARM), + rightLeg = map.getValue(EnumPart.RIGHT_LEG), + leftLeg = map.getValue(EnumPart.LEFT_LEG), + rightShoulderEntity = map.getValue(EnumPart.RIGHT_SHOULDER_ENTITY), + leftShoulderEntity = map.getValue(EnumPart.LEFT_SHOULDER_ENTITY), + rightWing = map.getValue(EnumPart.RIGHT_WING), + leftWing = map.getValue(EnumPart.LEFT_WING), + cape = map.getValue(EnumPart.CAPE), + child = child, + ) + + fun neutral() = PlayerPose( + head = Part(), + body = Part(), + rightArm = Part(-5f, 2f, 0f), + leftArm = Part(5f, 2f, 0f), + rightLeg = Part(-1.9f, 12f, 0.1f), + leftLeg = Part(1.9f, 12f, 0.1f), + rightShoulderEntity = Part(), + leftShoulderEntity = Part(), + rightWing = Part(-5f, 0f, 0f, 15f.degrees, 0f, 15f.degrees), + leftWing = Part(5f, 0f, 0f, 15f.degrees, 0f, (-15f).degrees), + // Values determined experimentally because MC doesn't use the regular ModelPart variables for the cape + cape = Part(0f, 0f, 2f, PI.toFloat() - 0.1f, 0f, -PI.toFloat()), + child = false, + ) + + private val Float.degrees + get() = this / 180f * PI.toFloat() + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/backend/RenderBackend.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/backend/RenderBackend.kt new file mode 100644 index 0000000..270e973 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/backend/RenderBackend.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.backend + +import gg.essential.model.util.UVertexConsumer + +interface RenderBackend { + suspend fun readTexture(name: String, bytes: ByteArray): Texture + + interface Texture { + val width: Int + val height: Int + } + + interface VertexConsumerProvider { + fun provide(texture: Texture, block: (UVertexConsumer) -> Unit) + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/collision/CollisionProvider.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/collision/CollisionProvider.kt new file mode 100644 index 0000000..87ea969 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/collision/CollisionProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.collision + +import dev.folomeev.kotgl.matrix.vectors.Vec3 + +interface CollisionProvider { + /** + * Tries to move a particle with radius [size] at [pos] by [offset]. + * Returns the actual offset it can move until a collision would occur as well as the normal of the surface it + * collided with. + */ + fun query(pos: Vec3, size: Float, offset: Vec3): Pair? + + object None : CollisionProvider { + override fun query(pos: Vec3, size: Float, offset: Vec3): Pair? = null + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/collision/PlaneCollisionProvider.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/collision/PlaneCollisionProvider.kt new file mode 100644 index 0000000..d5e60a4 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/collision/PlaneCollisionProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.collision + +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import dev.folomeev.kotgl.matrix.vectors.dot +import dev.folomeev.kotgl.matrix.vectors.mutables.minus +import dev.folomeev.kotgl.matrix.vectors.mutables.normalize +import dev.folomeev.kotgl.matrix.vectors.mutables.plusScaled +import dev.folomeev.kotgl.matrix.vectors.mutables.times +import dev.folomeev.kotgl.matrix.vectors.sqrLength +import dev.folomeev.kotgl.matrix.vectors.vecUnitY +import dev.folomeev.kotgl.matrix.vectors.vecZero +import kotlin.math.absoluteValue + + +/** + * Represents an infinite plane as defined by the given [normal] and [pointOnPlane]. + */ +class PlaneCollisionProvider( + private val pointOnPlane: Vec3, + private val normal: Vec3, +) : CollisionProvider { + override fun query(pos: Vec3, size: Float, offset: Vec3): Pair? { + // Whether we are currently "on top" of the plane (that is, on the side to which the normal is pointing) + val topSide = pos.dot(normal) > pointOnPlane.dot(normal) + + // Where are we looking to go + val direction = offset.normalize() + val dirDotNorm = direction.dot(normal) + if (dirDotNorm.absoluteValue < 0.001) { + return null // parallel to plane, will never collide; don't care about points in plane + } else if (dirDotNorm > 0 == topSide) { + return null // moving away from the plane, will never collide + } + + val offsetPlane = pointOnPlane.plusScaled(if (topSide) size else -size, normal) + val distanceToPlane = offsetPlane.minus(pos).dot(normal) / dirDotNorm + return if (offset.sqrLength() < distanceToPlane.sqr()) { + null + } else { + Pair(direction.times(distanceToPlane), normal) + } + } + + companion object { + val PlaneXZ = PlaneCollisionProvider(vecZero(), vecUnitY()) + } +} + +private fun Float.sqr() = this * this diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/file/AnimationFile.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/AnimationFile.kt new file mode 100644 index 0000000..f79d5b5 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/AnimationFile.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.file + +import gg.essential.cosmetics.events.AnimationEvent +import gg.essential.model.Channels +import gg.essential.model.Keyframe +import gg.essential.model.Keyframes +import gg.essential.model.molang.MolangExpression +import gg.essential.model.molang.MolangVec3 +import gg.essential.model.molang.parseMolangExpression +import gg.essential.model.util.ListOrSingle +import gg.essential.model.util.TreeMap +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer +import kotlin.jvm.JvmStatic + +@Serializable +data class AnimationFile( + val animations: Map = emptyMap(), + val triggers: List = emptyList(), +) { + @Serializable + data class Animation( + val loop: Loop = Loop.False, + @SerialName("animation_length") + val animationLength: Float? = null, + val bones: Map = emptyMap(), + @SerialName("particle_effects") + val particleEffects: Map> = emptyMap(), + @SerialName("sound_effects") + val soundEffects: Map> = emptyMap(), + ) { + @Serializable + data class ParticleEffect( + val effect: String, + val locator: String? = null, + @SerialName("pre_effect_script") + val preEffectScript: MolangExpression? = null, + ) + + @Serializable + data class SoundEffect( + val effect: String, + ) + } + + @Serializable(with = LoopSerializer::class) + enum class Loop { + False, + True, + HoldOnLastFrame, + } + + companion object { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @JvmStatic + fun parse(str: String): AnimationFile = json.decodeFromString(str) + } +} + +internal class LoopSerializer : KSerializer { + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + + override fun deserialize(decoder: Decoder): AnimationFile.Loop = + when (val json = (decoder as JsonDecoder).decodeJsonElement().jsonPrimitive.content) { + "false" -> AnimationFile.Loop.False + "true" -> AnimationFile.Loop.True + "hold_on_last_frame" -> AnimationFile.Loop.HoldOnLastFrame + else -> throw IllegalArgumentException("Unexpected value \"$json\"") + } + + override fun serialize(encoder: Encoder, value: AnimationFile.Loop) = + (encoder as JsonEncoder).encodeJsonElement( + when (value) { + AnimationFile.Loop.False -> JsonPrimitive(false) + AnimationFile.Loop.True -> JsonPrimitive(true) + AnimationFile.Loop.HoldOnLastFrame -> JsonPrimitive("hold_on_last_frame") + } + ) +} + +internal class KeyframesSerializer : KSerializer { + override val descriptor: SerialDescriptor = InnerSerializer.descriptor + override fun deserialize(decoder: Decoder): Keyframes = Keyframes(InnerSerializer.deserialize(decoder)) + override fun serialize(encoder: Encoder, value: Keyframes) = InnerSerializer.serialize(encoder, value.frames) + + private object InnerSerializer : JsonTransformingSerializer>(serializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement = + if (element is JsonObject) element else buildJsonObject { put("0", element) } + } +} + +internal object KeyframeSerializer : KSerializer { + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + override fun deserialize(decoder: Decoder): Keyframe = parse((decoder as JsonDecoder).decodeJsonElement()) + override fun serialize(encoder: Encoder, value: Keyframe) = throw UnsupportedOperationException() + + private fun parse(json: JsonElement): Keyframe = with(json) { + fun JsonElement.parseMolangVector(): MolangVec3 = if (this is JsonArray) { + if (size == 3) { + MolangVec3( + (get(0) as JsonPrimitive).parseMolangExpression(), + (get(1) as JsonPrimitive).parseMolangExpression(), + (get(2) as JsonPrimitive).parseMolangExpression() + ) + } else { + (get(0) as JsonPrimitive).parseMolangExpression().let { MolangVec3(it, it, it) } + } + } else { + (this as JsonPrimitive).parseMolangExpression().let { MolangVec3(it, it, it) } + } + if (this is JsonObject) { + val pre = get("pre")?.parseMolangVector() + val post = get("post")!!.parseMolangVector() + val smooth = get("lerp_mode")?.jsonPrimitive?.contentOrNull == "catmullrom" + Keyframe(pre ?: post, post, smooth) + } else { + parseMolangVector().let { Keyframe(it, it, false) } + } + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/file/ModelFile.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/ModelFile.kt new file mode 100644 index 0000000..bbbe38b --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/ModelFile.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +@file:UseSerializers(Vector3AsFloatArraySerializer::class) + +package gg.essential.model.file + +import gg.essential.model.Side +import gg.essential.model.Vector3 +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.builtins.FloatArraySerializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlin.jvm.JvmStatic + +@Serializable +data class ModelFile( + @SerialName("minecraft:geometry") + val geometries: List = emptyList(), +) { + @Serializable + data class Geometry( + val description: Description, + val bones: List = emptyList(), + ) + + @Serializable + data class Description( + val identifier: String, + @SerialName("texture_width") val textureWidth: Int, + @SerialName("texture_height") val textureHeight: Int, + ) + + @Serializable + data class Bone( + val name: String, + val parent: String? = null, + val pivot: Vector3 = Vector3(), + val rotation: Vector3 = Vector3(), + val mirror: Boolean = false, + val side: Side? = null, + val cubes: List = emptyList(), + ) + + @Serializable + data class Cube( + val origin: Vector3, + val size: Vector3, + val uv: Uvs, + val mirror: Boolean? = null, + val inflate: Float = 0f, + ) + + @Serializable(with = UvsSerializer::class) + sealed class Uvs { + @Serializable(with = UvBoxSerializer::class) + class Box(val uv: FloatArray) : Uvs() + + @Serializable + class PerFace( + val north: UvFace? = null, + val east: UvFace? = null, + val south: UvFace? = null, + val west: UvFace? = null, + val up: UvFace? = null, + val down: UvFace? = null, + ) : Uvs() + } + + @Serializable + class UvFace( + val uv: FloatArray, + @SerialName("uv_size") + val size: FloatArray, + ) + + companion object { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @JvmStatic + fun parse(str: String): ModelFile = json.decodeFromString(str) + } +} + +private class Vector3AsFloatArraySerializer : KSerializer { + private val inner = FloatArraySerializer() + override val descriptor = inner.descriptor + override fun deserialize(decoder: Decoder): Vector3 = + decoder.decodeSerializableValue(inner).let { (x, y, z) -> Vector3(x, y, z) } + override fun serialize(encoder: Encoder, value: Vector3) = + encoder.encodeSerializableValue(inner, floatArrayOf(value.x, value.y, value.z)) +} + +private class UvsSerializer : JsonContentPolymorphicSerializer(ModelFile.Uvs::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = + when (element) { + is JsonObject -> ModelFile.Uvs.PerFace.serializer() + else -> ModelFile.Uvs.Box.serializer() + } +} + +private class UvBoxSerializer : KSerializer { + private val inner = FloatArraySerializer() + override val descriptor = inner.descriptor + override fun deserialize(decoder: Decoder): ModelFile.Uvs.Box = ModelFile.Uvs.Box(inner.deserialize(decoder)) + override fun serialize(encoder: Encoder, value: ModelFile.Uvs.Box) = inner.serialize(encoder, value.uv) +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/file/ParticlesFile.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/ParticlesFile.kt new file mode 100644 index 0000000..699814c --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/ParticlesFile.kt @@ -0,0 +1,755 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +// Note: Most doc comments in here have been copied from https://bedrock.dev/docs/stable/Particles +// or the Microsoft docs it is based on. These do not have any clear license. DO NOT REDISTRIBUTE! + +package gg.essential.model.file + +import dev.folomeev.kotgl.matrix.vectors.Vec4 +import dev.folomeev.kotgl.matrix.vectors.mutables.lerp +import dev.folomeev.kotgl.matrix.vectors.vec4 +import gg.essential.model.molang.LiteralExpr +import gg.essential.model.molang.MolangContext +import gg.essential.model.molang.MolangExpression +import gg.essential.model.molang.MolangExpression.Companion.ONE +import gg.essential.model.molang.MolangExpression.Companion.ZERO +import gg.essential.model.molang.MolangVec3 +import gg.essential.model.molang.parseMolangExpression +import gg.essential.model.util.ListOrSingle +import gg.essential.model.util.PairAsList +import gg.essential.model.util.TreeMap +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject + +@Serializable +data class ParticlesFile( + @SerialName("particle_effect") + val particleEffect: ParticleEffect, +) { + @Serializable + data class ParticleEffect( + val description: Description, + val curves: Map = emptyMap(), + val events: Map = emptyMap(), + val components: ParticleEffectComponents = ParticleEffectComponents(), + ) + + @Serializable + data class Description( + val identifier: String, + @SerialName("basic_render_parameters") + val basicRenderParameters: BasicRenderParameters = BasicRenderParameters(), + ) + + @Serializable + data class BasicRenderParameters( + val material: Material = Material.Cutout, + // TODO texture, maybe some day + ) + + @Serializable + enum class Material(val needsSorting: Boolean) { + @SerialName("particles_add") + Add(true), + @SerialName("particles_alpha") + Cutout(false), + @SerialName("particles_blend") + Blend(true), + } + + @Serializable + data class Curve( + val type: Type, + val nodes: List, + val input: MolangExpression, + @SerialName("horizontal_range") + val range: MolangExpression = ONE, + ) { + @Serializable + enum class Type { + @SerialName("linear") + Linear, + @SerialName("bezier") + Bezier, + @SerialName("catmull_rom") + CatmullRom, + @SerialName("bezier_chain") + BezierChain, + } + } + + @Serializable + data class Event( + val sequence: List? = null, + val randomize: List<@Serializable(with = WeightedEventSerializer::class) RandomizeOption>? = null, + @SerialName("particle_effect") + val particle: Particle? = null, + @SerialName("sound_effect") + val sound: Sound? = null, + val expression: MolangExpression? = null, + ) { + @Serializable + data class RandomizeOption( + val weight: Float, + val value: Event, + ) + + @Serializable + data class Particle( + val effect: String, + val type: Type, + @SerialName("pre_effect_expression") + val preEffectExpression: MolangExpression = ZERO, + ) { + @Serializable + enum class Type { + @SerialName("emitter") + Emitter, + @SerialName("emitter_bound") + EmitterBound, + @SerialName("particle") + Particle, + @SerialName("particle_with_velocity") + ParticleWithVelocity, + ; + + val isParticle: Boolean + get() = this == Particle || this == ParticleWithVelocity + + val inheritVelocity: Boolean + get() = this == ParticleWithVelocity + + val isBound: Boolean + get() = this == EmitterBound + } + } + + @Serializable + data class Sound( + @SerialName("event_name") + val event: String, + ) + + private class WeightedEventSerializer : JsonTransformingSerializer(RandomizeOption.serializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement = if (element is JsonObject) { + buildJsonObject { + put("weight", element["weight"]!!) + put("value", JsonObject(element.filterKeys { it != "weight" })) + } + } else element + + override fun transformSerialize(element: JsonElement): JsonElement { + return JsonObject((element as JsonObject).filterKeys { it != "value" } + element["value"] as JsonObject) + } + } + } + + companion object { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @JvmStatic + fun parse(str: String): ParticlesFile = json.decodeFromString(str) + } +} + +@Serializable +data class ParticleEffectComponents( + @SerialName("minecraft:emitter_local_space") + val emitterLocalSpace: EmitterLocalSpace? = null, + @SerialName("minecraft:emitter_initialization") + val emitterInitialization: EmitterInitialization? = null, + @SerialName("minecraft:emitter_lifetime_events") + val emitterLifetimeEvents: EmitterLifetimeEvents = EmitterLifetimeEvents(), + @SerialName("minecraft:emitter_lifetime_looping") + val emitterLifetimeLooping: EmitterLifetimeLooping? = null, + @SerialName("minecraft:emitter_lifetime_once") + val emitterLifetimeOnce: EmitterLifetimeOnce? = null, + @SerialName("minecraft:emitter_lifetime_expression") + val emitterLifetimeExpression: EmitterLifetimeExpression? = null, + @SerialName("minecraft:emitter_rate_instant") + val emitterRateInstant: EmitterRateInstant? = null, + @SerialName("minecraft:emitter_rate_steady") + val emitterRateSteady: EmitterRateSteady? = null, + @SerialName("minecraft:emitter_shape_point") + val emitterShapePoint: EmitterShapePoint? = null, + @SerialName("minecraft:emitter_shape_sphere") + val emitterShapeSphere: EmitterShapeSphere? = null, + @SerialName("minecraft:emitter_shape_box") + val emitterShapeBox: EmitterShapeBox? = null, + @SerialName("minecraft:emitter_shape_disc") + val emitterShapeDisc: EmitterShapeDisc? = null, + @SerialName("minecraft:particle_lifetime_events") + val particleLifetimeEvents: ParticleLifetimeEvents = ParticleLifetimeEvents(), + @SerialName("minecraft:particle_appearance_billboard") + val particleAppearanceBillboard: ParticleAppearanceBillboard? = null, + @SerialName("minecraft:particle_appearance_tinting") + val particleAppearanceTinting: ParticleAppearanceTinting? = null, + /** When this component exists, particle will be tinted by local lighting conditions in-game. */ + @SerialName("minecraft:particle_appearance_lighting") + val particleAppearanceLighting: Unit? = null, + @SerialName("minecraft:particle_initial_speed") + val particleInitialSpeed: MolangExpression = ZERO, + @SerialName("minecraft:particle_initial_spin") + val particleInitialSpin: ParticleInitialSpin? = null, + @SerialName("minecraft:particle_initialization") + val particleInitialization: ParticleInitialization? = null, + @SerialName("minecraft:particle_motion_collision") + val particleMotionCollision: ParticleMotionCollision? = null, + @SerialName("minecraft:particle_motion_dynamic") + val particleMotionDynamic: ParticleMotionDynamic? = null, + @SerialName("minecraft:particle_motion_parametric") + val particleMotionParametric: ParticleMotionParametric? = null, + @SerialName("minecraft:particle_lifetime_expression") + val particleLifetimeExpression: ParticleLifetimeExpression? = null, +) { + /** + * This component specifies the frame of reference of the emitter. + * Applies only when the emitter is attached to an entity. + * When 'position' is true, the particles will simulate in entity space, otherwise they will simulate in world space. + * Rotation works the same way for rotation. + * Default is false for both, which makes the particles emit relative to the emitter, then simulate independently from the emitter. + * Note that rotation = true and position = false is an invalid option. + */ + @Serializable + data class EmitterLocalSpace( + val position: Boolean = false, + val rotation: Boolean = false, + /** When true, add the emitter's velocity to the initial particle velocity. */ + val velocity: Boolean = false, + ) + + @Serializable + data class EmitterInitialization( + @SerialName("creation_expression") + val creationExpression: MolangExpression? = null, + @SerialName("per_update_expression") + val perUpdateExpression: MolangExpression? = null, + ) + + /** Allows for lifetime events on the emitter to trigger certain events. */ + @Serializable + data class EmitterLifetimeEvents( + /** fires when the emitter is created */ + @SerialName("creation_event") + val creationEvents: ListOrSingle = emptyList(), + /** fires when the emitter expires (does not wait for particles to expire too) */ + @SerialName("expiration_event") + val expirationEvents: ListOrSingle = emptyList(), + /** a series of times, that trigger the event; these get fired on every loop the emitter goes through */ + val timeline: TreeMap> = TreeMap(emptyMap()), + /** a series of distances, that trigger the event; these get fired when the emitter has moved by the specified input distance */ + @SerialName("travel_distance_events") + val travelDistanceEvents: TreeMap> = TreeMap(emptyMap()), + /** these get fired every time the emitter has moved the specified input distance from the last time it was fired */ + @SerialName("looping_travel_distance_events") + val loopingTravelDistanceEvents: List = emptyList(), + ) { + @Serializable + data class LoopingDistance( + val distance: Float, + val effects: List, + ) + } + + /** Emitter will loop until it is removed. */ + @Serializable + data class EmitterLifetimeLooping( + /** emitter will emit particles for this time per loop; evaluated once per particle emitter loop */ + @SerialName("active_time") + val activeTime: MolangExpression = LiteralExpr(10f), + /** emitter will pause emitting particles for this time per loop; evaluated once per particle emitter loop */ + @SerialName("sleep_time") + val sleepTime: MolangExpression = ZERO, + ) + + /** Emitter will execute once, and once the lifetime ends or the number of particles allowed to emit have emitted, the emitter expires. */ + @Serializable + data class EmitterLifetimeOnce( + /** how long the particles emit for; evaluated once */ + @SerialName("active_time") + val activeTime: MolangExpression = LiteralExpr(10f), + ) + + /** Emitter will turn 'on' when the activation expression is non-zero, and will turn 'off' when it's zero. */ + @Serializable + data class EmitterLifetimeExpression( + /** When the expression is non-zero, the emitter will emit particles; Evaluated every frame */ + @SerialName("activation_expression") + val activationExpression: MolangExpression = ONE, + + /** Emitter will expire if the expression is non-zero; Evaluated every frame */ + @SerialName("expiration_expression") + val expirationExpression: MolangExpression = ZERO, + ) + + /** All particles come out at once, then no more unless the emitter loops. */ + @Serializable + data class EmitterRateInstant( + /** this many particles are emitted at once; evaluated once per particle emitter loop */ + /** how ofter a particle is emitted, in particles/sec; evaluated once per particle emitted */ + @SerialName("num_particles") + val numParticles: MolangExpression = LiteralExpr(10f), + ) + + /** Particles come out at a steady or Molang rate over time. */ + @Serializable + data class EmitterRateSteady( + /** how ofter a particle is emitted, in particles/sec; evaluated once per particle emitted */ + @SerialName("spawn_rate") + val spawnRate: MolangExpression = ONE, + /** maximum number of particles that can be active at once for this emitter; evaluated once per particles emitter loop */ + @SerialName("max_particles") + val maxParticles: MolangExpression = LiteralExpr(50f), + ) + + /** All particles come out of a point offset from the emitter. */ + @Serializable + data class EmitterShapePoint( + /** specifies the offset from the emitter to emit the particles; evaluated once per particle emitted */ + val offset: MolangVec3 = MolangVec3.ZERO, + /** specifies the direction of particles; evaluated once per particle emitted */ + val direction: MolangVec3 = MolangVec3.ZERO, + ) + + /** specifies the direction of particles emitted from a shape which has volume */ + @Serializable(with = Direction.Serializer::class) + sealed class Direction { + /** particle direction towards center of shape */ + @Serializable(with = InwardsSerializer::class) + object Inwards : Direction() + /** particle direction away from center of shape */ + @Serializable(with = OutwardsSerializer::class) + object Outwards : Direction() + /** particle direction as specified by molang expression */ + @Serializable(with = CustomSerializer::class) + data class Custom(val vec: MolangVec3) : Direction() + + internal object Serializer : JsonContentPolymorphicSerializer(Direction::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = when { + element is JsonPrimitive && element.content == "inwards" -> Inwards.serializer() + element is JsonPrimitive && element.content == "outwards" -> Outwards.serializer() + else -> Custom.serializer() + } + } + + private object InwardsSerializer : ObjectAsString("inwards", Inwards) + private object OutwardsSerializer : ObjectAsString("outwards", Outwards) + internal object CustomSerializer : KSerializer { + private val inner = MolangVec3.serializer() + override val descriptor: SerialDescriptor = inner.descriptor + override fun deserialize(decoder: Decoder) = Custom(inner.deserialize(decoder)) + override fun serialize(encoder: Encoder, value: Custom) = inner.serialize(encoder, value.vec) + } + + private open class ObjectAsString(private val str: String, private val value: T) : KSerializer { + private val inner = String.serializer() + override val descriptor: SerialDescriptor = inner.descriptor + override fun deserialize(decoder: Decoder) = inner.deserialize(decoder).let { value } + override fun serialize(encoder: Encoder, value: T) = inner.serialize(encoder, str) + } + } + + /** All particles come out of a box of the specified size from the emitter. */ + @Serializable + data class EmitterShapeBox( + /** specifies the offset from the emitter to emit the particles; evaluated once per particle emitted */ + val offset: MolangVec3 = MolangVec3.ZERO, + /** box dimensions; these are the half dimensions, the box is formed centered on the emitter with the box extending in the 3 principal x/y/z axes by these values */ + @SerialName("half_dimensions") + val halfDimensions: MolangVec3, + /** emit only from the surface of the box */ + @SerialName("surface_only") + val surfaceOnly: Boolean = false, + /** specifies the direction of particles; evaluated once per particle emitted */ + val direction: Direction = Direction.Outwards, + ) + + /** This component spawns particles using a disc shape, particles can be spawned inside the shape or on its outer perimeter. */ + @Serializable + data class EmitterShapeDisc( + /** specifies the normal of the disc plane, the disc will be perpendicular to this direction */ + @SerialName("plane_normal") + val planeNormal: MolangVec3 = MolangVec3.UNIT_Y, + /** specifies the offset from the emitter to emit the particles; evaluated once per particle emitted */ + val offset: MolangVec3 = MolangVec3.ZERO, + /** disc radius; evaluated once per particle emitted */ + val radius: MolangExpression = ONE, + /** emit only from the edge of the disc */ + @SerialName("surface_only") + val surfaceOnly: Boolean = false, + /** specifies the direction of particles; evaluated once per particle emitted */ + val direction: Direction = Direction.Outwards, + ) + + /** All particles come out of a sphere offset from the emitter. */ + @Serializable + data class EmitterShapeSphere( + /** specifies the offset from the emitter to emit the particles; evaluated once per particle emitted */ + val offset: MolangVec3 = MolangVec3.ZERO, + /** sphere radius; evaluated once per particle emitted */ + val radius: MolangExpression = ONE, + /** emit only from the surface of the sphere */ + @SerialName("surface_only") + val surfaceOnly: Boolean = false, + /** specifies the direction of particles; evaluated once per particle emitted */ + val direction: Direction = Direction.Outwards, + ) + + @Serializable + data class ParticleLifetimeEvents( + /** fires when the particle is created */ + @SerialName("creation_event") + val creationEvents: ListOrSingle = emptyList(), + /** fires when the particle expires */ + @SerialName("expiration_event") + val expirationEvents: ListOrSingle = emptyList(), + /** a series of times, that trigger the event */ + val timeline: TreeMap> = TreeMap(emptyMap()), + ) + + @Serializable + data class ParticleAppearanceBillboard( + /** specifies the x/y size of the billboard; evaluated every frame */ + val size: PairAsList, + /** used to orient the billboard */ + @SerialName("facing_camera_mode") + @JsonNames("face_camera_mode") + val facingCameraMode: FacingCameraMode, + /** Specifies how to calculate the direction of a particle, this will be used by facing modes that require a direction as input (for instance: lookat_direction and direction) */ + val direction: Direction = Direction.FromVelocity(), + /** specifies the UVs for the particle */ + val uv: UV = UV(uv = Pair(ZERO, ZERO), uvSize = Pair(ONE, ONE)), + ) { + @Serializable + enum class FacingCameraMode { + /** aligned to the camera, perpendicular to the view axis */ + @SerialName("rotate_xyz") + RotateXYZ, + /** aligned to camera, but rotating around world y axis */ + @SerialName("rotate_y") + RotateY, + /** aimed at the camera, biased towards world y up */ + @SerialName("lookat_xyz") + LookAtXYZ, + /** aimed at the camera, but rotating around world y axis */ + @SerialName("lookat_y") + LookAtY, + /** this is a thing that exists but the docs don't list it (but they do mention it in the explanation for the `direction` field) */ + @SerialName("lookat_direction") + LookAtDirection, + /** unrotated particle x axis is along the direction vector, unrotated y axis attempts to aim upwards */ + @SerialName("direction_x") + DirectionX, + /** unrotated particle y axis is along the direction vector, unrotated x axis attempts to aim upwards */ + @SerialName("direction_y") + DirectionY, + /** billboard face is along the direction vector, unrotated y axis attempts to aim upwards */ + @SerialName("direction_z") + DirectionZ, + /** orient the particles to match the emitter's transform (the billboard plane will match the transform's xy plane). */ + @SerialName("emitter_transform_xy") + EmitterTransformXY, + /** orient the particles to match the emitter's transform (the billboard plane will match the transform's xz plane). */ + @SerialName("emitter_transform_xz") + EmitterTransformXZ, + /** orient the particles to match the emitter's transform (the billboard plane will match the transform's yz plane). */ + @SerialName("emitter_transform_yz") + EmitterTransformYZ, + } + + @OptIn(ExperimentalSerializationApi::class) + @Serializable + @JsonClassDiscriminator("mode") + sealed class Direction { + /** The direction matches the direction of the velocity. */ + @Serializable + @SerialName("derive_from_velocity") + data class FromVelocity( + /** the direction is set if the speed of the particle is above the threshold */ + val minSpeedThreshold: Float = 0.01f, + ) : Direction() { + @Transient + val minSpeedThresholdSqr: Float = minSpeedThreshold * minSpeedThreshold + } + + /** The direction is specified in the json definition using a vector of floats or molang expressions. */ + @Serializable + @SerialName("custom") + data class Custom( + /** specifies the direction vector */ + @SerialName("custom_direction") + val direction: MolangVec3, + ) : Direction() + } + + @Serializable + data class UV( + /** specifies the assumed texture width/height */ + @SerialName("texture_width") + val textureWidth: Int = 1, + @SerialName("texture_height") + val textureHeight: Int = 1, + /** Assuming the specified texture width and height, use these uv coordinates; evaluated every frame */ + val uv: PairAsList? = null, + @SerialName("uv_size") + val uvSize: PairAsList? = null, + /** alternate way via specifying a flipbook animation */ + val flipbook: Flipbook? = null, + ) { + /** a flipbook animation uses pieces of the texture to animate, by stepping over time from one "frame" to another */ + @Serializable + data class Flipbook( + /** upper-left corner of starting UV patch */ + @SerialName("base_UV") + val base: PairAsList, + /** size of UV patch */ + @SerialName("size_UV") + val size: PairAsList, + /** how far to move the UV patch each frame */ + @SerialName("step_UV") + val step: PairAsList, + /** default frames per second */ + @SerialName("frames_per_second") + val framePerSecond: Float = 1f, + /** maximum frame number, with first frame being frame 1 */ + @SerialName("max_frame") + val maxFrame: MolangExpression, + /** adjust fps to match lifetime of particle */ + @SerialName("stretch_to_lifetime") + val stretchToLifetime: Boolean = false, + /** makes the animation loop when it reaches the end? */ + val loop: Boolean = false, + ) + } + } + + /** This component sets the color tinting of the particle. */ + @Serializable + data class ParticleAppearanceTinting( + val color: MolangColorOrGradient, + ) + + /** Starts the particle with a specified orientation and rotation rate. */ + @Serializable + data class ParticleInitialSpin( + /** specifies the initial rotation in degrees; evaluated once */ + val rotation: MolangExpression = ZERO, + /** specifies the spin rate in degrees/second; evaluated once */ + @SerialName("rotation_rate") + val rotationRate: MolangExpression = ZERO, + ) + + /** Starts the particle with a specified render expression. */ + @Serializable + data class ParticleInitialization( + @SerialName("per_render_expression") + val perRenderExpression: MolangExpression? = null, + ) + + /** + * This component enables collisions between the terrain and the particle. + * Collision detection in Minecraft consists of detecting an intersection, moving to a nearby non-intersecting point + * for the particle (if possible), and setting its direction to not be aimed towards the collision (usually + * perpendicular to the collision surface). + * Note that if this component doesn't exist, there will be no collision. + */ + @Serializable + data class ParticleMotionCollision( + /** enables collision when true/non-zero; evaluated every frame */ + val enabled: MolangExpression = ONE, + /** + * alters the speed of the particle when it has collided + * useful for emulating friction/drag when colliding, e.g a particle + * that hits the ground would slow to a stop. + * This drag slows down the particle by this amount in blocks/sec + * when in contact + */ + @SerialName("collision_drag") + val collisionDrag: Float = 0f, + /** + * used for bouncing/not-bouncing + * Set to 0.0 to not bounce, 1.0 to bounce back up to original hight + * and in-between to lose speed after bouncing. Set to >1.0 to gain energy on each bounce + */ + @SerialName("coefficient_of_restitution") + val coefficientOfRestitution: Float = 0f, + /** + * used to minimize interpenetration of particles with the environment + * note that this must be less than or equal to 1/2 block + */ + @SerialName("collision_radius") + val collisionRadius: Float, + /** triggers expiration on contact if true */ + @SerialName("expire_on_contact") + val expireOnContact: Boolean = false, + /** triggers an event */ + val events: ListOrSingle = emptyList(), + ) { + @Serializable + data class Event( + /** triggers the specified event if the conditions are met */ + val event: String, + /** minimum speed for event triggering */ + @SerialName("min_speed") + val minSpeed: Float = 2f, + ) + } + + /** This component specifies the dynamic properties of the particle, from a simulation standpoint what forces act upon the particle? */ + @Serializable + data class ParticleMotionDynamic( + /** the linear acceleration applied to the particle; An example would be gravity which is [0, -9.8, 0]; evaluated every frame */ + @SerialName("linear_acceleration") + val linearAcceleration: MolangVec3 = MolangVec3.ZERO, + /** acceleration = -linear_drag_coefficient*velocity; evaluated every frame */ + @SerialName("linear_drag_coefficient") + val linearDragCoefficient: MolangExpression = ZERO, + /** acceleration applies to the rotation speed of the particle; evaluated every frame */ + @SerialName("rotation_acceleration") + val rotationAcceleration: MolangExpression = ZERO, + /** rotation_acceleration += -rotation_rate*rotation_drag_coefficient */ + @SerialName("rotation_drag_coefficient") + val rotationDragCoefficient: MolangExpression = ZERO, + ) + + /** This component directly controls the particle. */ + @Serializable + data class ParticleMotionParametric( + /** directly set the position relative to the emitter; evaluated every frame */ + @SerialName("relative_position") + val relativePosition: MolangVec3 = MolangVec3.ZERO, + /** directly set the 3d direction of the particle; evaluated every frame */ + val direction: MolangVec3? = null, + /** directly set the rotation of the particle; evaluated every frame */ + val rotation: MolangExpression = ZERO, + ) + + /** Standard lifetime component. These expressions control the lifetime of the particle. */ + @Serializable + data class ParticleLifetimeExpression( + /** this expression makes the particle expire when true (non-zero); evaluated every frame */ + @SerialName("expiration_expression") + val expirationExpression: MolangExpression = ZERO, + /** particle will expire after this much time; evaluated once */ + @SerialName("max_lifetime") + val maxLifetime: MolangExpression, + ) +} + +@Serializable(with = MolangColorOrGradientSerializer::class) +sealed interface MolangColorOrGradient { + fun eval(context: MolangContext): Vec4 +} + +@Serializable(with = MolangColorSerializer::class) +data class MolangColor( + val r: MolangExpression, + val g: MolangExpression, + val b: MolangExpression, + val a: MolangExpression, +) : MolangColorOrGradient { + override fun eval(context: MolangContext): Vec4 = + vec4(r.eval(context), g.eval(context), b.eval(context), a.eval(context)) +} + +@Serializable +data class MolangGradient( + @Serializable(with = MolangGradientMapSerializer::class) + val gradient: TreeMap, + val interpolant: MolangExpression, +) : MolangColorOrGradient { + override fun eval(context: MolangContext): Vec4 { + val alpha = interpolant.eval(context) + val floor = gradient.floorEntry(alpha) + val ceil = gradient.ceilingEntry(alpha) + return when { + floor == null -> ceil!!.value.eval(context) + ceil == null -> floor.value.eval(context) + floor == ceil -> floor.value.eval(context) + else -> floor.value.eval(context).lerp(ceil.value.eval(context), (alpha - floor.key) / (ceil.key - floor.key)) + } + } +} + +internal object MolangColorOrGradientSerializer : JsonContentPolymorphicSerializer(MolangColorOrGradient::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = + if (element is JsonObject) MolangGradient.serializer() else MolangColor.serializer() +} + +internal object MolangColorSerializer : KSerializer { + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + override fun deserialize(decoder: Decoder): MolangColor = parse((decoder as JsonDecoder).decodeJsonElement()) + override fun serialize(encoder: Encoder, value: MolangColor) = throw UnsupportedOperationException() + + private fun parse(json: JsonElement): MolangColor = with(json) { + if (this is JsonArray) { + MolangColor( + (get(0) as JsonPrimitive).parseMolangExpression(), + (get(1) as JsonPrimitive).parseMolangExpression(), + (get(2) as JsonPrimitive).parseMolangExpression(), + (getOrNull(3) as JsonPrimitive?)?.parseMolangExpression() ?: ONE, + ) + } else { + val v = (this as JsonPrimitive).content.substring(1).padStart(8, 'f').toLong(16) + val a = ((v shr 24) and 0xff) / 255f + val r = ((v shr 16) and 0xff) / 255f + val g = ((v shr 8) and 0xff) / 255f + val b = (v and 0xff) / 255f + MolangColor(LiteralExpr(r), LiteralExpr(g), LiteralExpr(b), LiteralExpr(a)) + } + } +} + +internal object MolangGradientMapSerializer : KSerializer> { + private val mapSerializer = TreeMap.serializer(Float.serializer(), MolangColor.serializer()) + private val listSerializer = ListSerializer(MolangColor.serializer()) + + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + + override fun deserialize(decoder: Decoder): TreeMap { + val input = decoder as JsonDecoder + val tree = input.decodeJsonElement() + + return if (tree is JsonArray) { + val list = input.json.decodeFromJsonElement(listSerializer, tree) + TreeMap(list.withIndex().associate { (index, value) -> index.toFloat() / list.lastIndex to value }) + } else { + input.json.decodeFromJsonElement(mapSerializer, tree) + } + } + + override fun serialize(encoder: Encoder, value: TreeMap) = throw UnsupportedOperationException() +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/file/SoundDefinitionsFile.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/SoundDefinitionsFile.kt new file mode 100644 index 0000000..eaafa29 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/file/SoundDefinitionsFile.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.file + +import gg.essential.model.SoundCategory +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject + +@Serializable +class SoundDefinitionsFile( + @SerialName("sound_definitions") + val definitions: Map, +) { + @Serializable + class Definition( + val category: SoundCategory, + // Note: Min distance is not currently implemented. Minecraft uses a fixed 0 for all sounds. + @SerialName("min_distance") + val minDistance: Float = 0f, + @SerialName("max_distance") + val maxDistance: Float = 16f, + val sounds: List<@Serializable(with = SoundObjectOrNameSerializer::class) Sound>, + ) + + @Serializable + class Sound( + val name: String, + val stream: Boolean = false, + val volume: Float = 1f, + val pitch: Float = 1f, + @SerialName("is3D") + val directional: Boolean = true, + val weight: Int = 1, + ) + + private class SoundObjectOrNameSerializer : JsonTransformingSerializer(Sound.serializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement = if (element is JsonPrimitive) { + buildJsonObject { put("name", element) } + } else element + + override fun transformSerialize(element: JsonElement): JsonElement { + element as JsonObject + return if (element.size == 1) element.getValue("name") else element + } + } + + companion object { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @JvmStatic + fun parse(str: String): SoundDefinitionsFile = SoundDefinitionsFile(emptyMap()) + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/light/Light.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/light/Light.kt new file mode 100644 index 0000000..4a306e3 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/light/Light.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.light + +/** Value type containing sky and block light information at a single point. Equivalent to MC's packed light `int`s. */ +@JvmInline +value class Light(val value: UInt) { + constructor(blockLight: UShort, skyLight: UShort) : this(blockLight.toUInt() or (skyLight.toUInt() shl 16)) + + val blockLight: UShort + get() = value.toUShort() + + val skyLight: UShort + get() = (value shr 16).toUShort() + + override fun toString(): String = + "Light(block=$blockLight,sky=$skyLight)" + + companion object { + /** Completely dark. */ + val MIN_VALUE = Light(0u, 0u) + /** Fully bright. */ + val MAX_VALUE = Light(240u, 240u) + } +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/light/LightProvider.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/light/LightProvider.kt new file mode 100644 index 0000000..0d366b9 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/light/LightProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.light + +import dev.folomeev.kotgl.matrix.vectors.Vec3 + +/** Interface for querying light information at various points in a world. */ +interface LightProvider { + /** Queries the light information at a given world position. */ + fun query(pos: Vec3): Light + + /** Implementation which always returns maximum block and sky light. */ + object FullBright : LightProvider { + override fun query(pos: Vec3): Light = Light.MAX_VALUE + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangContext.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangContext.kt new file mode 100644 index 0000000..814319b --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangContext.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.molang + +import gg.essential.model.ParticleSystem +import kotlin.random.Random +import kotlin.reflect.KProperty + +class MolangContext( + val query: MolangQuery, + val variables: Variables = VariablesMap(), +) + +interface MolangQuery { + object Empty : MolangQuery +} + +/** Not an official query but used internally so we can get deterministic results if we need them. */ +interface MolangQueryRandom : MolangQuery { + val random: Random +} + +interface MolangQueryAnimation : MolangQuery { + val animTime: Float + /** Same as [animTime] but modulo animation length for looping animations. Not part of official Molang. */ + val animLoopTime: Float +} + +interface MolangQueryTime : MolangQuery { + val time: Float +} + +interface MolangQueryEntity : MolangQuery, MolangQueryTime { + val lifeTime: Float + val modifiedDistanceMoved: Float + val modifiedMoveSpeed: Float + /** Internal query for retrieving entity the position and rotation of the entity (for global-space particles). */ + val locator: ParticleSystem.Locator + + override val time: Float + get() = lifeTime +} + +interface Variables { + /** Returns the variable with the given name or null if no such variable exists. */ + fun getOrNull(name: String): Variable? + + /** Returns the variable with the given name. Initializing it with [initialValue] if it does not yet exist. */ + fun getOrPut(name: String, initialValue: Float = 0f): Variable + + /** Returns the value of the variable with the given name or 0 if no such variable exists. */ + operator fun get(name: String): Float = getOrNull(name)?.get() ?: 0f + + /** Sets the value of the variable with the given name. Creates the variable if it does not yet exist. */ + operator fun set(name: String, value: Float) = getOrPut(name).set(value) + + /** + * Returns a new [Variables] instance that contains the variables of `this` instance and the [fallback] instance. + * When both instances contain a variable, the one in `this` instance is returned. + * New variables will be create in `this` instance only. + */ + fun fallbackBackTo(fallback: Variables): Variables = VariablesWithFallback(this, fallback) + + interface Variable { + fun get(): Float + fun set(value: Float) + + operator fun getValue(thisRef: Any?, property: KProperty<*>): Float = get() + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Float) = set(value) + } +} + +class VariablesMap : Variables { + private val map = mutableMapOf() + + override fun getOrNull(name: String): Variables.Variable? = + map[name] + + override fun getOrPut(name: String, initialValue: Float): Variables.Variable = + map.getOrPut(name) { Variable(initialValue) } + + private class Variable(var field: Float) : Variables.Variable { + override fun get(): Float = field + override fun set(value: Float) { field = value } + } +} + +private class VariablesWithFallback(val primary: Variables, val fallback: Variables) : Variables { + override fun getOrNull(name: String): Variables.Variable? = primary.getOrNull(name) ?: fallback.getOrNull(name) + + override fun getOrPut(name: String, initialValue: Float): Variables.Variable = + getOrNull(name) ?: primary.getOrPut(name, initialValue) +} + diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangExpression.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangExpression.kt new file mode 100644 index 0000000..ad51898 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangExpression.kt @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.molang + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.round +import kotlin.math.sin +import kotlin.math.truncate +import kotlin.random.Random + +@Serializable(MolangSerializer::class) +interface MolangExpression { + fun eval(context: MolangContext): Float + + companion object { + val ZERO = LiteralExpr(0f) + val ONE = LiteralExpr(1f) + } +} + +interface MolangVariable { + fun assign(context: MolangContext, value: Float) +} + +data class LiteralExpr(val value: Float) : MolangExpression { + override fun eval(context: MolangContext): Float = value +} + +data class NegExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = -inner.eval(context) +} + +data class InvExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = 1 / inner.eval(context) +} + +data class AddExpr(val left: MolangExpression, val right: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = left.eval(context) + right.eval(context) +} + +data class MulExpr(val left: MolangExpression, val right: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = left.eval(context) * right.eval(context) +} + +data class SinExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = sin(inner.eval(context).toRadians()) +} + +data class CosExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = cos(inner.eval(context).toRadians()) +} + +data class FloorExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = floor(inner.eval(context)) +} + +data class CeilExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = ceil(inner.eval(context)) +} + +data class RoundExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = round(inner.eval(context)) +} + +data class TruncExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = truncate(inner.eval(context)) +} + +data class AbsExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = abs(inner.eval(context)) +} + +data class ClampExpr(val value: MolangExpression, val min: MolangExpression, val max: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = + value.eval(context).coerceIn(min.eval(context), max.eval(context)) +} + +data class RandomExpr(val low: MolangExpression, val high: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float { + val low = low.eval(context) + val high = high.eval(context) + val random = (context.query as? MolangQueryRandom)?.random ?: Random + return random.nextFloat() * (high - low) + low + } +} + +data class QueryExpr(val f: MolangQuery.() -> Float) : MolangExpression { + override fun eval(context: MolangContext): Float = context.query.run(f) + + companion object { + inline operator fun invoke(crossinline f: T.() -> Float) = + QueryExpr { (this as? T)?.let(f) ?: 0f } + } +} + +data class ComparisonExpr(val left: MolangExpression, val right: MolangExpression, val op: Op) : MolangExpression { + override fun eval(context: MolangContext): Float = + if (op.check(left.eval(context), right.eval(context))) 1f else 0f + + enum class Op(val check: (a: Float, b: Float) -> Boolean) { + Equal({ a, b -> a == b }), + NotEqual({ a, b -> a != b }), + LessThan({ a, b -> a < b }), + LessThanOrEqual({ a, b -> a <= b }), + GreaterThan({ a, b -> a > b }), + GreaterThanOrEqual({ a, b -> a >= b }), + } +} + +data class LogicalOrExpr(val left: MolangExpression, val right: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = + if (left.eval(context) != 0f || right.eval(context) != 0f) 1f else 0f +} + +data class LogicalAndExpr(val left: MolangExpression, val right: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float = + if (left.eval(context) != 0f && right.eval(context) != 0f) 1f else 0f +} + +data class TernaryExpr( + val condition: MolangExpression, + val trueCase: MolangExpression, + val falseCase: MolangExpression, +) : MolangExpression { + override fun eval(context: MolangContext): Float { + return (if (condition.eval(context) != 0f) trueCase else falseCase).eval(context) + } +} + +data class VariableExpr(val key: String) : MolangExpression, MolangVariable { + override fun eval(context: MolangContext): Float = context.variables[key] + override fun assign(context: MolangContext, value: Float) { + context.variables[key] = value + } +} + +data class AssignmentExpr(val variable: MolangVariable, val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float { + variable.assign(context, inner.eval(context)) + return 0f + } +} + +data class StatementsExpr(val statements: List, val result: MolangExpression = MolangExpression.ZERO) : MolangExpression { + override fun eval(context: MolangContext): Float { + for (statement in statements) { + statement.eval(context) + } + return result.eval(context) + } +} + +data class ReturnExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float { + throw Return(inner.eval(context)) + } + + internal class Return(val value: Float) : Throwable() +} + +data class ComplexExpr(val inner: MolangExpression) : MolangExpression { + override fun eval(context: MolangContext): Float { + return try { + inner.eval(context) + } catch (e: ReturnExpr.Return) { + e.value + } + } +} + +private fun Float.toRadians() = this / 180 * PI.toFloat() + +private class Parser(str: String) { + /** How many return expressions there are. Need to wrap the entire expression in a try-catch if any remain. */ + private var returns: Int = 0 + + val str = str.lowercase() + var i = 0 + + val curr get() = str[i] + + fun reads(char: Char): Boolean = reads { it == char } + fun reads(char: CharRange): Boolean = reads { it in char } + inline fun reads(f: (char: Char) -> Boolean): Boolean = when { + i >= str.length -> false + f(curr) -> { + i++ + skipWhitespace() + true + } + else -> false + } + + fun reads(s: String): Boolean = if (str.startsWith(s, i)) { + i += s.length + skipWhitespace() + true + } else { + false + } + + @Suppress("ControlFlowWithEmptyBody") + fun skipWhitespace() { + while (reads(' ')); + } + + @Suppress("ControlFlowWithEmptyBody") + fun parseLiteral(): LiteralExpr { + val start = i + while (reads('0'..'9')); + if (reads('.')) { + while (reads('0'..'9')); + } + reads('f') + return LiteralExpr(str.slice(start until i).replace(" ", "").toFloat()) + } + + fun parseIdentifier(): String { + val start = i + @Suppress("ControlFlowWithEmptyBody") + while (reads { it.isLetterOrDigit() || it == '_' }); + return str.slice(start until i).replace(" ", "") + } + + fun parseSimpleExpression(): MolangExpression = when { + reads('(') -> parseExpression().also { reads(')') } + curr in '0'..'9' -> parseLiteral() + curr == '-' -> { + reads('-') + NegExpr(parseSimpleExpression()) + } + reads("math.pi") -> LiteralExpr(PI.toFloat()) + reads("math.cos(") -> CosExpr(parseExpression()).also { reads(')') } + reads("math.sin(") -> SinExpr(parseExpression()).also { reads(')') } + reads("math.floor(") -> FloorExpr(parseExpression()).also { reads(')') } + reads("math.ceil(") -> CeilExpr(parseExpression()).also { reads(')') } + reads("math.round(") -> RoundExpr(parseExpression()).also { reads(')') } + reads("math.trunc(") -> TruncExpr(parseExpression()).also { reads(')') } + reads("math.abs(") -> AbsExpr(parseExpression()).also { reads(')') } + reads("math.clamp(") -> ClampExpr( + parseExpression().also { reads(',') }, + parseExpression().also { reads(',') }, + parseExpression(), + ).also { reads(')') } + reads("math.random(") -> RandomExpr(parseExpression().also { reads(',') }, parseExpression()).also { reads(')') } + reads("query.anim_time") -> QueryExpr { animTime } + reads("query.life_time") -> QueryExpr { lifeTime } + reads("query.modified_move_speed") -> QueryExpr { modifiedMoveSpeed } + reads("query.modified_distance_moved") -> QueryExpr { modifiedDistanceMoved } + reads("variable.") -> VariableExpr(parseIdentifier()) + else -> throw IllegalArgumentException("Unexpected character at index $i") + } + + fun parseProduct(): MolangExpression { + var left = parseSimpleExpression() + while (true) { + left = when { + reads('*') -> MulExpr(left, parseSimpleExpression()) + reads('/') -> MulExpr(left, InvExpr(parseSimpleExpression())) + else -> return left + } + } + } + + fun parseSum(): MolangExpression { + var left = parseProduct() + while (true) { + left = when { + reads('+') -> AddExpr(left, parseProduct()) + reads('-') -> AddExpr(left, NegExpr(parseProduct())) + else -> return left + } + } + } + + fun parseComparisons(): MolangExpression { + val left = parseSum() + return when { + reads("<=") -> ComparisonExpr(left, parseSum(), ComparisonExpr.Op.LessThanOrEqual) + reads(">=") -> ComparisonExpr(left, parseSum(), ComparisonExpr.Op.GreaterThanOrEqual) + reads('<') -> ComparisonExpr(left, parseSum(), ComparisonExpr.Op.LessThan) + reads('>') -> ComparisonExpr(left, parseSum(), ComparisonExpr.Op.GreaterThan) + else -> left + } + } + + fun parseEqualityChecks(): MolangExpression { + val left = parseComparisons() + return when { + reads("==") -> ComparisonExpr(left, parseSum(), ComparisonExpr.Op.Equal) + reads("!=") -> ComparisonExpr(left, parseSum(), ComparisonExpr.Op.NotEqual) + else -> left + } + } + + fun parseLogicalAnds(): MolangExpression { + var left = parseEqualityChecks() + while (true) { + left = when { + reads("&&") -> LogicalAndExpr(left, parseEqualityChecks()) + else -> return left + } + } + } + + fun parseLogicalOrs(): MolangExpression { + var left = parseLogicalAnds() + while (true) { + left = when { + reads("||") -> LogicalOrExpr(left, parseLogicalAnds()) + else -> return left + } + } + } + + fun parseTernary(): MolangExpression { + val condition = parseLogicalOrs() + return if (reads('?')) { + val trueCase = parseTernary() + reads(':') + val falseCase = parseTernary() + TernaryExpr(condition, trueCase, falseCase) + } else { + condition + } + } + + fun parseNullCoalescing(): MolangExpression { + return parseTernary() // TODO implement + } + + fun parseExpression(): MolangExpression { + return if (reads('{')) { + parseStatements().also { + reads('}') + } + } else { + parseNullCoalescing() + } + } + + fun parseAssignment(): MolangExpression { + val left = parseExpression() + if (!reads('=')) { + return left + } + if (left !is MolangVariable) { + throw IllegalArgumentException("Cannot assign value to $left") + } + val right = parseExpression() + return AssignmentExpr(left, right) + } + + fun parseStatement(): MolangExpression { + return when { + reads("return") -> ReturnExpr(parseExpression()).also { returns++ } + else -> parseAssignment() + } + } + + fun parseStatements(): MolangExpression { + val first = parseStatement() + if (!reads(';')) { + return first + } + val statements = mutableListOf(first) + while (i < str.length && curr != '}') { + statements.add(parseStatement()) + reads(';') + } + return StatementsExpr(statements) + } + + fun parseMolang(): MolangExpression { + var expr = parseStatements() + + // Complex molang expressions require a `return` statement to return a value other than 0. + // In most cases that'll be the last expression, so we can easily optimize it into a regular expression result. + if (expr is StatementsExpr && expr.result == MolangExpression.ZERO) { + val lastExpr = expr.statements.last() + if (lastExpr is ReturnExpr) { + expr = StatementsExpr(expr.statements.dropLast(1), lastExpr.inner) + returns-- + } + } + + // If there's still a `return` expression somewhere in this molang expression, we need to wrap the entire thing + // in a try-catch to handle it. + if (returns > 0) { + expr = ComplexExpr(expr) + } + + return expr + } + + fun fullyParseMolang(): MolangExpression { + return parseMolang().also { + if (i < str.length) { + throw IllegalArgumentException("Failed to fully parse input, remaining: ${str.substring(i)}") + } + } + } + + fun tryFullyParseMolang(): MolangExpression = try { + fullyParseMolang() + } catch (e: Exception) { + throw MolangParserException("Failed to parse `$str`:", e) + } +} + +class MolangParserException(message: String, cause: Throwable?) : Exception(message, cause) + +fun String.parseMolangExpression(): MolangExpression = Parser(this).tryFullyParseMolang() +fun JsonPrimitive.parseMolangExpression(): MolangExpression = when { + isString -> content.parseMolangExpression() + else -> LiteralExpr(content.toFloat()) +} + +internal object MolangSerializer : KSerializer { + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + + override fun deserialize(decoder: Decoder): MolangExpression = parse((decoder as JsonDecoder).decodeJsonElement()) + override fun serialize(encoder: Encoder, value: MolangExpression) = throw UnsupportedOperationException() + + private fun parse(json: JsonElement): MolangExpression = + (json as JsonPrimitive).parseMolangExpression() +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangVec3.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangVec3.kt new file mode 100644 index 0000000..0332a2e --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/molang/MolangVec3.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.molang + +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import dev.folomeev.kotgl.matrix.vectors.vec3 +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive + +@Serializable(with = MolangVec3Serializer::class) +data class MolangVec3(val x: MolangExpression, val y: MolangExpression, val z: MolangExpression) { + fun eval(context: MolangContext): Vec3 = + vec3(x.eval(context), y.eval(context), z.eval(context)) + + companion object { + val ZERO = MolangVec3(MolangExpression.ZERO, MolangExpression.ZERO, MolangExpression.ZERO) + val UNIT_X = MolangVec3(MolangExpression.ONE, MolangExpression.ZERO, MolangExpression.ZERO) + val UNIT_Y = MolangVec3(MolangExpression.ZERO, MolangExpression.ONE, MolangExpression.ZERO) + val UNIT_Z = MolangVec3(MolangExpression.ZERO, MolangExpression.ZERO, MolangExpression.ONE) + } +} + +// FIXME needs to be publicly accessible, otherwise kotlin throws this at runtime: +// java.lang.IllegalAccessError: tried to access class gg.essential.model.molang.MolangVec3Serializer from class +// gg.essential.model.file.ParticleEffectComponents$ParticleMotionParametric$$serializer +internal object MolangVec3Serializer : KSerializer { + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + override fun deserialize(decoder: Decoder): MolangVec3 = parse((decoder as JsonDecoder).decodeJsonElement()) + override fun serialize(encoder: Encoder, value: MolangVec3) = throw UnsupportedOperationException() + + private fun parse(json: JsonElement): MolangVec3 = when (json) { + is JsonArray -> { + val first = (json[0] as JsonPrimitive).parseMolangExpression() + val second = (json.getOrNull(1) as JsonPrimitive?)?.parseMolangExpression() ?: first + val third = (json.getOrNull(2) as JsonPrimitive?)?.parseMolangExpression() ?: second + MolangVec3(first, second, third) + } + is JsonPrimitive -> when (json.content) { + "x" -> MolangVec3.UNIT_X + "y" -> MolangVec3.UNIT_Y + "z" -> MolangVec3.UNIT_Z + else -> json.parseMolangExpression().let { MolangVec3(it, it, it) } + } + else -> throw SerializationException("Expected array or primitive, got $json") + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/AnySerializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/AnySerializer.kt new file mode 100644 index 0000000..7d91f22 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/AnySerializer.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +object AnySerializer : KSerializer { + private val inner = JsonElement.serializer() + override val descriptor = inner.descriptor + + override fun serialize(encoder: Encoder, value: Any) = + encoder.encodeSerializableValue(inner, value.toJsonElement()) + + override fun deserialize(decoder: Decoder): Any = + decoder.decodeSerializableValue(inner).toAny()!! + + @Suppress("UNCHECKED_CAST") + private fun Any?.toJsonElement(): JsonElement = when (this) { + is Map<*, *> -> JsonObject((this as Map).mapValues { it.value.toJsonElement() }) + is List<*> -> JsonArray((this as List).map { it.toJsonElement() }) + else -> toJsonPrimitive() + } + + private fun Any?.toJsonPrimitive(): JsonElement = when (this) { + null -> JsonNull + is Boolean -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is String -> JsonPrimitive(this) + else -> throw IllegalArgumentException(this.toString()) + } + + private fun JsonElement.toAny(): Any? = when (this) { + is JsonPrimitive -> toAny() + is JsonArray -> map { it.toAny() } + is JsonObject -> mapValues { it.value.toAny() } + } + + private fun JsonPrimitive.toAny(): Any? = when { + isString -> content + content == "null" -> null + content == "true" -> true + content == "false" -> false + else -> content.toIntOrNull() ?: content.toLongOrNull() ?: content.toDoubleOrNull() + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/ColorAsRgbSerializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/ColorAsRgbSerializer.kt new file mode 100644 index 0000000..9e04043 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/ColorAsRgbSerializer.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object ColorAsRgbSerializer : KSerializer { + private val inner = Int.serializer() + override val descriptor = inner.descriptor + + override fun serialize(encoder: Encoder, value: Color) = + encoder.encodeSerializableValue(inner, (value.rgba shr 8).toInt()) + + override fun deserialize(decoder: Decoder): Color = + Color.rgba((decoder.decodeSerializableValue(inner).toUInt() shl 8) or 255u) +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/Instant.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/Instant.kt new file mode 100644 index 0000000..8ba07be --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/Instant.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +expect class Instant : Comparable { + fun toEpochMilli(): Long +} + +expect fun now(): Instant +expect fun instant(epochMillis: Long): Instant + +expect fun instantFromIso8601(str: String): Instant +expect fun instantToIso8601(instant: Instant): String diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsIso8601Serializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsIso8601Serializer.kt new file mode 100644 index 0000000..785f4bb --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsIso8601Serializer.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object InstantAsIso8601Serializer : KSerializer { + private val inner = String.serializer() + override val descriptor = inner.descriptor + + override fun serialize(encoder: Encoder, value: Instant) = + encoder.encodeSerializableValue(inner, instantToIso8601(value)) + + override fun deserialize(decoder: Decoder): Instant = + instantFromIso8601(decoder.decodeSerializableValue(inner)) +} \ No newline at end of file diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsMillisSerializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsMillisSerializer.kt new file mode 100644 index 0000000..877df63 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/InstantAsMillisSerializer.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object InstantAsMillisSerializer : KSerializer { + private val inner = Long.serializer() + override val descriptor = inner.descriptor + + override fun serialize(encoder: Encoder, value: Instant) = + encoder.encodeSerializableValue(inner, value.toEpochMilli()) + + override fun deserialize(decoder: Decoder): Instant = + instant(decoder.decodeSerializableValue(inner)) +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/ListOrSingleSerializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/ListOrSingleSerializer.kt new file mode 100644 index 0000000..577f337 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/ListOrSingleSerializer.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonArray + +typealias ListOrSingle = @Serializable(with = ListOrSingleSerializer::class) List + +class ListOrSingleSerializer( + elementSerializer: KSerializer, +) : JsonTransformingSerializer>(ListSerializer(elementSerializer)) { + override fun transformDeserialize(element: JsonElement): JsonElement = + if (element is JsonArray) { + element + } else { + buildJsonArray { add(element) } + } + + override fun transformSerialize(element: JsonElement): JsonElement = + (element as? JsonArray)?.singleOrNull() ?: element +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/PairAsListSerializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/PairAsListSerializer.kt new file mode 100644 index 0000000..3982f17 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/PairAsListSerializer.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.PairSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject + +typealias PairAsList = + @Serializable(with = PairAsListSerializer::class) + Pair + +class PairAsListSerializer( + keySerializer: KSerializer, + valueSerializer: KSerializer +) : JsonTransformingSerializer>(PairSerializer(keySerializer, valueSerializer)) { + override fun transformDeserialize(element: JsonElement): JsonElement { + return if (element is JsonArray) { + buildJsonObject { + put("first", element[0]) + put("second", element[1]) + } + } else { + element + } + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/PlayerPoseManager.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/PlayerPoseManager.kt new file mode 100644 index 0000000..217c80a --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/PlayerPoseManager.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import gg.essential.cosmetics.WearablesManager +import gg.essential.mod.cosmetics.CosmeticSlot +import gg.essential.model.ModelAnimationState +import gg.essential.model.ModelInstance +import gg.essential.model.ParticleSystem +import gg.essential.model.backend.PlayerPose +import gg.essential.model.molang.MolangQueryEntity + +/** + * A [ModelInstance] and its most recently active animation state. We need to explicitly track the + * most recently active animation state because we want to hold that after the emote is done playing + * while transitioning away from it. + */ +private typealias EmoteState = Pair + +/** Manages a player pose to smoothly transition in of, out of and between emotes. */ +class PlayerPoseManager( + private val entity: MolangQueryEntity, +) { + private var lastTime = entity.lifeTime + + /** Previously equipped emote */ + private var transitionFrom: EmoteState? = null + /** Progress transitioning away from the previously equipped emote. */ + private var transitionFromProgress: Float = 0f + set(value) { + field = value.coerceIn(0f, 1f) + } + + /** Currently equipped emote */ + private var transitionTo: EmoteState? = null + /** Progress transitioning to the currently equipped emote. */ + private var transitionToProgress: Float = 0f + set(value) { + field = value.coerceIn(0f, 1f) + } + + fun update(wearablesManager: WearablesManager) = + update( + wearablesManager.models.values + .find { it.cosmetic.type.slot == CosmeticSlot.EMOTE } + ?.takeUnless { it.animationState.active.isEmpty() } + ) + + fun update(target: ModelInstance?) { + val previous = transitionTo + if (target == null) { + if (previous != null) { + // Switch from an emote to nothing + transitionFrom = previous + transitionFromProgress = 1f - transitionToProgress + transitionTo = null + transitionToProgress = 1f + } else { + // From nothing to nothing + } + } else { + if (target != previous?.first) { + // From one emote (or nothing) to another emote + transitionFrom = previous + transitionFromProgress = 1f - transitionToProgress + transitionTo = Pair(target, target.animationState.active.firstOrNull()) + transitionToProgress = 0f + } else { + // Target unchanged + } + } + + /** Stores the most recently active animation state for use after the emote has finished. */ + fun EmoteState.updateAnimationState() = + first.animationState.active.firstOrNull()?.let { Pair(first, it) } ?: this + + transitionTo = transitionTo?.updateAnimationState() + transitionFrom = transitionFrom?.updateAnimationState() + + val now = entity.lifeTime + val dt = now - lastTime.also { lastTime = now } + val progress = dt / transitionTime + transitionToProgress += progress + transitionFromProgress += progress + } + + /** + * Computes the final pose for the player based on their vanilla [basePose], equipped cosmetics, + * the current emote and, if not yet fully transitioned, the previous emote. + */ + fun computePose(wearablesManager: WearablesManager, basePose: PlayerPose): PlayerPose { + var transformedPose = basePose + + // Apply interpolated emote pose + transformedPose = computePose(transformedPose) + + // Apply pose animations from all other cosmetics (if any) + for ((cosmetic, model) in wearablesManager.models) { + if (cosmetic.type.slot == CosmeticSlot.EMOTE) { + continue // already handled separately before the loop + } + transformedPose = model.computePose(transformedPose) + } + + return transformedPose + } + + /** + * Computes the final pose for the player based on their vanilla [basePose], the current emote + * and, if not yet fully transitioned, the previous emote. + */ + private fun computePose(basePose: PlayerPose): PlayerPose { + var transformedPose = basePose + + fun EmoteState.computePoseForAffectedParts(basePose: PlayerPose): PlayerPose { + val (instance, latestAnimation) = this + // Construct a fake animation state so we can hold the most recently active animation + // even after it is over + // (so we can smoothly interpolate away from it) + val animationState = ModelAnimationState(instance.animationState.entity, ParticleSystem.Locator.Zero) + latestAnimation?.let { animationState.active.add(it) } + // Compute the pose for this model based on the neutral pose (i.e. suppressing + // input/base/vanilla pose) + val modelPose = instance.model.computePose(PlayerPose.neutral(), animationState) + // but keep the base pose for those parts that were not affected by the animation + val affectedParts = + animationState.active.flatMapTo(mutableSetOf()) { it.animation.affectsPoseParts } + return PlayerPose.fromMap( + basePose.keys.associateWith { + if (it in affectedParts) modelPose[it] else basePose[it] + }, + basePose.child, + ) + } + + val transitionFrom = transitionFrom + if (transitionFrom != null) { + val fromPose = transitionFrom.computePoseForAffectedParts(transformedPose) + transformedPose = interpolatePose(fromPose, transformedPose, transitionFromProgress) + } + + val transitionTo = transitionTo + if (transitionTo != null) { + val toPose = transitionTo.computePoseForAffectedParts(transformedPose) + transformedPose = interpolatePose(transformedPose, toPose, transitionToProgress) + } + + return transformedPose + } + + private fun interpolatePose(a: PlayerPose, b: PlayerPose, alpha: Float): PlayerPose { + return when { + alpha == 0f -> a + alpha == 1f -> b + a == b -> a + else -> + PlayerPose.fromMap( + a.keys.associateWith { interpolatePosePart(a[it], b[it], alpha) }, + a.child + ) + } + } + + private fun interpolatePosePart( + a: PlayerPose.Part, + b: PlayerPose.Part, + alpha: Float + ): PlayerPose.Part { + if (a == b) { + return a + } + val oneMinusAlpha = 1 - alpha + fun interp(a: Float, b: Float) = a * oneMinusAlpha + b * alpha + fun interpRot(a: Float, b: Float): Float { + // Wrap angles around such that we'll always interpolate the short way round + var aMod = a.mod(TAU) + var bMod = b.mod(TAU) + if (aMod - bMod > HALF_TAU) { + aMod -= TAU + } else if (bMod - aMod > HALF_TAU) { + bMod -= TAU + } + return interp(aMod, bMod) + } + return PlayerPose.Part( + pivotX = interp(a.pivotX, b.pivotX), + pivotY = interp(a.pivotY, b.pivotY), + pivotZ = interp(a.pivotZ, b.pivotZ), + rotateAngleX = interpRot(a.rotateAngleX, b.rotateAngleX), + rotateAngleY = interpRot(a.rotateAngleY, b.rotateAngleY), + rotateAngleZ = interpRot(a.rotateAngleZ, b.rotateAngleZ), + // we use this only for (mostly small) non-uniform scaling and vanilla doesn't use it at + // all, doubt anyone will notice we're cheating and I'm not keen on doing matrix + // interpolation + extra = + when { + a.extra == null -> b.extra + b.extra == null -> a.extra + else -> if (alpha < 0.5) a.extra else b.extra + }, + ) + } + + companion object { + private const val HALF_TAU = kotlin.math.PI.toFloat() + private const val TAU = HALF_TAU * 2 + + /** + * Time (in seconds) we'll take to transition from one emote (or none) to another emote (or + * none). + */ + const val transitionTime = 0.3f + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/TreeMap.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/TreeMap.kt new file mode 100644 index 0000000..d0774bb --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/TreeMap.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = TreeMapSerializer::class) +class TreeMap, V> private constructor( + private val list: List>, + private val map: Map = list.associate { (k, v) -> k to v }, +) : Map by map { + + constructor(map: Map) : this(map.entries.sortedBy { it.key }) + + fun lowerEntry(key: K): Map.Entry? = + findEntry(below = true, inclusive = false, key) + + fun floorEntry(key: K): Map.Entry? = + findEntry(below = true, inclusive = true, key) + + fun ceilingEntry(key: K): Map.Entry? = + findEntry(below = false, inclusive = true, key) + + fun higherEntry(key: K): Map.Entry? = + findEntry(below = false, inclusive = false, key) + + private fun findEntry(below: Boolean, inclusive: Boolean, key: K): Map.Entry? { + if (list.isEmpty()) return null + val index = list.binarySearchBy(key) { it.key } + return list.getOrNull( + if (index >= 0) { + when { + inclusive -> index + below -> index - 1 + else -> index + 1 + } + } else { + val insertionPoint = -index - 1 + when { + below -> insertionPoint - 1 + else -> insertionPoint + } + } + ) + } + + fun lowestEntry(): Map.Entry? = list.firstOrNull() + + fun lastKey(): K? = list.lastOrNull()?.key + + override fun toString(): String = map.toString() + override fun hashCode(): Int = map.hashCode() + override fun equals(other: Any?): Boolean = (other as? TreeMap<*, *>)?.list?.equals(list) == true +} + +private class TreeMapSerializer, V>(kSerializer: KSerializer, vSerializer: KSerializer) : KSerializer> { + private val inner = MapSerializer(kSerializer, vSerializer) + override val descriptor = inner.descriptor + override fun serialize(encoder: Encoder, value: TreeMap) = encoder.encodeSerializableValue(inner, value) + override fun deserialize(decoder: Decoder): TreeMap = TreeMap(decoder.decodeSerializableValue(inner)) +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/UMatrixStack.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/UMatrixStack.kt new file mode 100644 index 0000000..f87bd00 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/UMatrixStack.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import dev.folomeev.kotgl.matrix.matrices.Mat3 +import dev.folomeev.kotgl.matrix.matrices.Mat4 +import dev.folomeev.kotgl.matrix.matrices.identityMat3 +import dev.folomeev.kotgl.matrix.matrices.identityMat4 +import dev.folomeev.kotgl.matrix.matrices.mat3 +import dev.folomeev.kotgl.matrix.matrices.mutables.MutableMat3 +import dev.folomeev.kotgl.matrix.matrices.mutables.MutableMat4 +import dev.folomeev.kotgl.matrix.matrices.mutables.timesSelf +import dev.folomeev.kotgl.matrix.matrices.mutables.toMutable +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import kotlin.math.PI +import kotlin.math.acos +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +class UMatrixStack( + private val stack: MutableList, +) { + constructor( + model: Mat4 = identityMat4(), + normal: Mat3 = identityMat3(), + ) : this(mutableListOf(Entry(model.toMutable(), normal.toMutable()))) + + fun translate(x: Float, y: Float, z: Float) { + if (x == 0f && y == 0f && z == 0f) return + stack.last().run { + // kotgl's builtin translate functions put the translation in the wrong place (last row + // instead of column) + model.timesSelf( + identityMat4().toMutable().apply { + m03 = x + m13 = y + m23 = z + } + ) + } + } + + fun translate(vec: Vec3) = translate(vec.x, vec.y, vec.z) + + fun scale(value: Float) { + scale(value, value, value) + } + + fun scale(x: Float, y: Float, z: Float) { + if (x == 1f && y == 1f && z == 1f) return + return stack.last().run { + // kotgl's builtin scale functions also scale the translate values + model.timesSelf( + identityMat4().toMutable().apply { + m00 = x + m11 = y + m22 = z + } + ) + if (x == y && y == z) { + if (x < 0f) { + normal.timesSelf(-1f) + } + } else { + val ix = 1f / x + val iy = 1f / y + val iz = 1f / z + val rt = cbrt(ix * iy * iz) + normal.timesSelf( + identityMat3().toMutable().apply { + m00 = rt * ix + m11 = rt * iy + m22 = rt * iz + } + ) + } + } + } + + fun rotate(angle: Float, x: Float, y: Float, z: Float, degrees: Boolean) { + if (angle == 0f) return + stack.last().run { + val angleRadians = if (degrees) (angle / 180 * PI).toFloat() else angle + val c = cos(angleRadians) + val s = sin(angleRadians) + val oneMinusC = 1 - c + val xx = x * x + val xy = x * y + val xz = x * z + val yy = y * y + val yz = y * z + val zz = z * z + val xs = x * s + val ys = y * s + val zs = z * s + val rotation = mat3( + xx * oneMinusC + c, + xy * oneMinusC - zs, + xz * oneMinusC + ys, + xy * oneMinusC + zs, + yy * oneMinusC + c, + yz * oneMinusC - xs, + xz * oneMinusC - ys, + yz * oneMinusC + xs, + zz * oneMinusC + c, + ) + model.timesSelf(rotation.toMat4()) + normal.timesSelf(rotation) + } + } + + fun rotate(q: Quaternion) { + val n = 1 / sqrt(1 - q.w * q.w) + rotate(2 * acos(q.w), q.x * n, q.y * n, q.z * n, degrees = false) + } + + fun fork() = UMatrixStack(mutableListOf(stack.last().deepCopy())) + + fun push() { + stack.add(stack.last().deepCopy()) + } + + fun pop() { + stack.removeLast() + } + + fun peek(): Entry = stack.last() + + data class Entry(val model: MutableMat4, val normal: MutableMat3) { + fun deepCopy() = Entry(model.copyOf(), normal.copyOf()) + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/UVertexConsumer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/UVertexConsumer.kt new file mode 100644 index 0000000..b0fbf1d --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/UVertexConsumer.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import gg.essential.model.light.Light + +interface UVertexConsumer { + fun pos(stack: UMatrixStack, x: Double, y: Double, z: Double): UVertexConsumer + + fun tex(u: Double, v: Double): UVertexConsumer + + fun norm(stack: UMatrixStack, x: Float, y: Float, z: Float): UVertexConsumer + + fun color(color: Color): UVertexConsumer + + fun light(light: Light): UVertexConsumer + + fun endVertex(): UVertexConsumer +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/kotgl.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/kotgl.kt new file mode 100644 index 0000000..bbc424a --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/kotgl.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import dev.folomeev.kotgl.matrix.matrices.Mat3 +import dev.folomeev.kotgl.matrix.matrices.Mat4 +import dev.folomeev.kotgl.matrix.matrices.mat4 +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import dev.folomeev.kotgl.matrix.vectors.Vec4 +import dev.folomeev.kotgl.matrix.vectors.mutables.MutableVec3 +import dev.folomeev.kotgl.matrix.vectors.mutables.MutableVec4 +import dev.folomeev.kotgl.matrix.vectors.mutables.mutableVec3 +import dev.folomeev.kotgl.matrix.vectors.mutables.set +import dev.folomeev.kotgl.matrix.vectors.vec3 +import dev.folomeev.kotgl.matrix.vectors.vec4 +import kotlin.math.asin +import kotlin.math.atan2 + +inline fun Vec3.times(mat: Mat3, out: (Float, Float, Float) -> T) = + out( + x * mat.m00 + y * mat.m01 + z * mat.m02, + x * mat.m10 + y * mat.m11 + z * mat.m12, + x * mat.m20 + y * mat.m21 + z * mat.m22, + ) + +inline fun Vec4.times(mat: Mat4, out: (Float, Float, Float, Float) -> T) = + out( + x * mat.m00 + y * mat.m01 + z * mat.m02 + w * mat.m03, + x * mat.m10 + y * mat.m11 + z * mat.m12 + w * mat.m13, + x * mat.m20 + y * mat.m21 + z * mat.m22 + w * mat.m23, + x * mat.m30 + y * mat.m31 + z * mat.m32 + w * mat.m33, + ) + +/** + * Computes the matrix-vector product `mat * this`. + */ +fun Vec3.times(mat: Mat3) = times(mat, ::vec3) + +/** + * Computes the matrix-vector product `mat * this`. + */ +fun Vec4.times(mat: Mat4) = times(mat, ::vec4) + +/** + * Computes the matrix-vector product `mat * this`, storing the result in `this`. + */ +fun MutableVec3.timesSelf(mat: Mat3) = times(mat, ::set) + +/** + * Computes the matrix-vector product `mat * this`, storing the result in `this`. + */ +fun MutableVec4.timesSelf(mat: Mat4) = times(mat, ::set) + +inline fun Vec3.rotateBy(q: Quaternion, out: (Float, Float, Float) -> T): T = + with(q * Quaternion(x, y, z, 0f) * q.conjugate()) { out(x, y, z) } + +/** + * Applies the given rotation represented as a [Quaternion] to this vector, returning the resulting vector. + */ +fun Vec3.rotateBy(q: Quaternion) = rotateBy(q, ::mutableVec3) + +/** + * Applies the given rotation represented as a [Quaternion] to this vector, storing the result in `this`. + */ +fun MutableVec3.rotateSelfBy(q: Quaternion) = rotateBy(q, ::set) + +fun Mat4.getRotationEulerZYX() = + vec3( + // + // Z * Y * X + // + // cz -sz 0 cy 0 sy 1 0 0 + // sz cz 0 * 0 1 0 * 0 cx -sx + // 0 0 1 -sy 0 cy 0 sx cx + // + // czcy -sz cysy 1 0 0 + // szcy cz szsy * 0 cx -sx + // -sy 0 cy 0 sx cx + // + // czcy -cxsz+sxcysy sxsz+cxcysy + // szcy czcx+sxszsy -sxcz+cxszsy + // -sy sxcy cxcy + // + x = atan2(m21, m22), + y = asin(-m20.coerceIn(-1f, 1f)), + z = atan2(m10, m00), + ) + +fun Mat3.toMat4() = mat4(m00, m01, m02, 0f, m10, m11, m12, 0f, m20, m21, m22, 0f, 0f, 0f, 0f, 1f) + +fun FloatArray.toMat4() = mat4 { row, col -> this[row * 4 + col] } + +fun Mat4.toFloatArray() = + toRowMajor() + +fun Mat4.toRowMajor() = + floatArrayOf( + m00, + m01, + m02, + m03, + m10, + m11, + m12, + m13, + m20, + m21, + m22, + m23, + m30, + m31, + m32, + m33, + ) + diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/platformMath.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/platformMath.kt new file mode 100644 index 0000000..7e0fd15 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/platformMath.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +internal expect fun cbrt(value: Float): Float + +internal expect fun base64Encode(value: ByteArray): String +internal expect fun base64Decode(value: String): ByteArray +internal expect fun md5Hex(value: ByteArray): String diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/model/util/quaternion.kt b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/quaternion.kt new file mode 100644 index 0000000..2c8e42b --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/model/util/quaternion.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import dev.folomeev.kotgl.matrix.matrices.Mat3 +import dev.folomeev.kotgl.matrix.matrices.mat3 +import dev.folomeev.kotgl.matrix.vectors.Vec3 +import dev.folomeev.kotgl.matrix.vectors.dot +import dev.folomeev.kotgl.matrix.vectors.mutables.cross +import dev.folomeev.kotgl.matrix.vectors.mutables.minus +import dev.folomeev.kotgl.matrix.vectors.mutables.mutableVec3 +import dev.folomeev.kotgl.matrix.vectors.mutables.normalizeSelf +import dev.folomeev.kotgl.matrix.vectors.mutables.times +import dev.folomeev.kotgl.matrix.vectors.vecZero +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +data class Quaternion(val x: Float, val y: Float, val z: Float, val w: Float) { + fun normalize(): Quaternion { + val invNorm = 1 / (x * x + y * y + z * z + w * w) + return Quaternion(x * invNorm, y * invNorm, z * invNorm, w * invNorm) + } + + fun conjugate(): Quaternion = + Quaternion(-x, -y, -z, w) + + fun invert(): Quaternion { + val invNorm = 1 / (x * x + y * y + z * z + w * w) + return Quaternion(-x * invNorm, -y * invNorm, -z * invNorm, w * invNorm) + } + + operator fun times(q: Quaternion): Quaternion { + if (this === Identity) return q + if (q === Identity) return this + return Quaternion( + w * q.x + x * q.w + y * q.z - z * q.y, + w * q.y - x * q.z + y * q.w + z * q.x, + w * q.z + x * q.y - y * q.x + z * q.w, + w * q.w - x * q.x - y * q.y - z * q.z, + ) + } + + operator fun times(v: Vec3): Vec3 = v.rotateBy(this) + + /** + * Projects this quaternion into a quaternion rotating around the given [axis]. + * This given [axis] must be normalized. + * + * The result is the twist part of the + * [swing-twist decomposition](https://www.euclideanspace.com/maths/geometry/rotations/for/decomposition/) + * of this quaternion. + */ + fun projectAroundAxis(axis: Vec3): Quaternion { + val rotationAxis = mutableVec3(x, y, z) + val projectedLength = axis.dot(rotationAxis) + val projectedAxis = axis.times(projectedLength) + return if (projectedLength > 0) { + Quaternion(projectedAxis.x, projectedAxis.y, projectedAxis.z, w).normalize() + } else { + Quaternion(-projectedAxis.x, -projectedAxis.y, -projectedAxis.z, -w).normalize() + } + } + + /** + * If this quaternion represents a camera orientation, this method returns the opposite orientation. + * The roll of the camera is unaffected. + */ + fun opposite() = this * Y180 + + companion object { + val Identity = Quaternion(0f, 0f, 0f, 1f) + + /** Equivalent to `fromAxisAngle(vecUnitX(), PI)` */ + val X180 = Quaternion(1f, 0f, 0f, 0f) + /** Equivalent to `fromAxisAngle(vecUnitY(), PI)` */ + val Y180 = Quaternion(0f, 1f, 0f, 0f) + /** Equivalent to `fromAxisAngle(vecUnitZ(), PI)` */ + val Z180 = Quaternion(0f, 0f, 1f, 0f) + + /** Returns a quaternion that rotates around the given axis by the given angle. */ + fun fromAxisAngle(axis: Vec3, angleRad: Float): Quaternion { + if (angleRad == 0f) { + return Identity + } + val s = sin(angleRad * 0.5f) + val c = cos(angleRad * 0.5f) + return Quaternion(axis.x * s, axis.y * s, axis.z * s, c) + } + + /** + * Returns a quaternion that rotates from the negative Z axis to the given [lookAt] vector. + * This matches OpenGL convention, that is, a non-rotated camera is looking towards negative Z. + * [up] must be normalized. + * + * E.g. + * ``` + * fromLookAt(vec3(0, 0, -1), vecUnitY()) == Unit + * fromLookAt(vec3(0, 0, 1), vecUnitY()) == fromAxisAngle(vecUnitY(), PI) + * fromLookAt(vec3(-1, 0, 0), vecUnitY()) == fromAxisAngle(vecUnitY(), PI / 2) + * fromLookAt(vec3(1, 0, 0), vecUnitY()) == fromAxisAngle(vecUnitY(), -PI / 2) + * fromLookAt(vec3(0, 1, 0), vecUnitZ()) == fromAxisAngle(vecUnitX(), PI / 2) + * ``` + */ + fun fromLookAt(lookAt: Vec3, up: Vec3): Quaternion { + val z = vecZero().minus(lookAt).normalizeSelf() + val x = up.cross(z).normalizeSelf() + val y = z.cross(x) + return fromRotationMatrix(mat3( + x.x, y.x, z.x, + x.y, y.y, z.y, + x.z, y.z, z.z, + )) + } + + /** Returns a quaternion that rotates the same way as the given (pure) rotation matrix. */ + fun fromRotationMatrix(m: Mat3): Quaternion = with(m) { + // Algorithm and variable names from https://en.wikipedia.org/wiki/Rotation_matrix#Quaternion + // Rotational variants from https://github.com/JOML-CI/JOML/blob/1067a8e0e176866b266efeeaebb1674e35ab9eb1/src/main/java/org/joml/Quaternionf.java#L716 + val trace = m00 + m11 + m22 + if (trace >= 0) { + val r = sqrt(trace + 1f) + val s = 0.5f / r + return Quaternion( + (m21 - m12) * s, + (m02 - m20) * s, + (m10 - m01) * s, + 0.5f * r, + ) + } else { + if (m00 >= m11 && m00 >= m22) { + val r = sqrt(m00 - (m11 + m22) + 1f) + val s = 0.5f / r + return Quaternion( + 0.5f * r, + (m10 + m01) * s, + (m02 + m20) * s, + (m21 - m12) * s, + ) + } else if (m11 > m22) { + val r = sqrt(m11 - (m22 + m00) + 1f) + val s = 0.5f / r + return Quaternion( + (m10 + m01) * s, + 0.5f * r, + (m21 + m12) * s, + (m02 - m20) * s, + ) + } else { + val r = sqrt(m22 - (m00 + m11) + 1f) + val s = 0.5f / r + return Quaternion( + (m02 + m20) * s, + (m21 + m12) * s, + 0.5f * r, + (m10 - m01) * s, + ) + } + } + } + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/network/cosmetics/Cosmetic.kt b/cosmetics/src/commonMain/kotlin/gg/essential/network/cosmetics/Cosmetic.kt new file mode 100644 index 0000000..f4c0a62 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/network/cosmetics/Cosmetic.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +@file:UseSerializers(InstantAsMillisSerializer::class) + +package gg.essential.network.cosmetics + +import gg.essential.mod.EssentialAsset +import gg.essential.mod.cosmetics.CosmeticAssets +import gg.essential.mod.cosmetics.CosmeticTier +import gg.essential.mod.cosmetics.CosmeticType +import gg.essential.mod.cosmetics.SkinLayer +import gg.essential.mod.cosmetics.settings.CosmeticProperty +import gg.essential.mod.cosmetics.settings.CosmeticPropertyType +import gg.essential.mod.cosmetics.settings.CosmeticSetting +import gg.essential.mod.cosmetics.settings.CosmeticSettings +import gg.essential.mod.cosmetics.settings.variant +import gg.essential.model.Side +import gg.essential.model.util.now +import gg.essential.model.util.Instant +import gg.essential.model.util.InstantAsMillisSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.UseSerializers + +@Serializable +data class Cosmetic( + val id: String, + val type: CosmeticType, + val tier: CosmeticTier, + val displayNames: Map, + val files: Map, + val properties: List, + val storePackageId: Int, + val prices: Map, + val tags: Set, + val createdAt: Instant, + val availableAfter: Instant?, + val availableUntil: Instant?, + val skinLayers: Map, + val categories: Map, + val defaultSortWeight: Int, +) { + val baseAssets: CosmeticAssets + val assetVariants: Map + init { + val base = mutableMapOf() + val variants = mutableMapOf>() + for ((path, asset) in files) { + val prefix = "variants/" + if (path.startsWith(prefix)) { + val delim = path.indexOf("/", prefix.length) + if (delim < 0) continue + val variantName = path.substring(prefix.length, delim) + val variantPath = path.substring(delim + 1) + variants.getOrPut(variantName, ::mutableMapOf)[variantPath] = asset + } else { + base[path] = asset + } + } + baseAssets = CosmeticAssets(base) + assetVariants = variants.mapValues { (_, files) -> CosmeticAssets(base + files) } + } + + fun assets(variant: String): CosmeticAssets = assetVariants[variant] ?: baseAssets + fun assets(settings: CosmeticSettings) = assets(settings.variant ?: defaultVariantName) + + val displayName: String + get() = displayNames["en_us"] ?: id + + val isLegacy: Boolean + get() = "LEGACY" in tags + + // Rename to isNew when removing flag + val isCosmeticNew: Boolean + get() = "NEW" in tags + + val priceCoinsNullable = prices["coins"]?.toInt() + + val priceCoins = priceCoinsNullable ?: 0 + + val isPurchasable = priceCoinsNullable != null && !requiresUnlockAction() + + // Rename to isFree when removing flag + val isCosmeticFree = priceCoinsNullable == 0 + + val defaultSide: Side? + get() = property()?.data?.side + + // Added some convenient variants values, especially convenient in Java + val variants: List? + get() = property()?.data?.variants + + val defaultVariant: CosmeticProperty.Variants.Variant? + get() = variants?.firstOrNull() + + val defaultVariantName: String + get() = defaultVariant?.name ?: "" + + val defaultVariantSetting: CosmeticSetting.Variant? + get() = defaultVariant?.let { CosmeticSetting.Variant(id, true, CosmeticSetting.Variant.Data(it.name)) } + + fun isAvailable(): Boolean { + return availableAfter != null && isAvailableAt(now()) + } + + fun isAvailableAt(dateTime: Instant): Boolean { + return availableAfter != null && availableAfter < dateTime && (availableUntil == null || availableUntil > dateTime) + } + + fun getDisplayName(locale: String): String? { + return displayNames[locale] + } + + fun getPrice(currency: String): Double? { + return prices[currency] + } + + /** + * Returns true if the cosmetic requires a specific action to unlock separate from the standard + * purchase flow. + */ + fun requiresUnlockAction(): Boolean { + return properties.any { it.type == CosmeticPropertyType.REQUIRES_UNLOCK_ACTION } + } + + private fun tagValue(key: String) = + tags.firstNotNullOfOrNull { if (it.startsWith(key)) it.substring(key.length) else null } + + @Transient + val partnerCreator: Boolean = "PARTNER" in tags + + @Transient + val partnerMod: String? = tagValue("mod:") + + @Transient + val partnerEvent: String? = tagValue("event:") + + @Transient + val isPartnered: Boolean = (partnerCreator || partnerMod != null || partnerEvent != null) && "NON_PARTNER" !in tags + + @Transient + val partnerName: String? = + property()?.data?.en_US + ?: partnerMod + ?: (if (partnerCreator) "cosmetic.$id.partnername" else null) + ?: partnerEvent?.let { "studio.$it" } + + @Transient + val emoteInterruptionTriggers: CosmeticProperty.InterruptsEmote.Data = + property()?.data + ?: CosmeticProperty.InterruptsEmote.Data() + + inline fun property(): T? { + return properties.firstNotNullOfOrNull { it as? T } + } + + inline fun properties(): List { + return properties.filterIsInstance() + } +} diff --git a/cosmetics/src/commonMain/kotlin/gg/essential/serialization/SnakeAsUpperCaseSerializer.kt b/cosmetics/src/commonMain/kotlin/gg/essential/serialization/SnakeAsUpperCaseSerializer.kt new file mode 100644 index 0000000..63d20e7 --- /dev/null +++ b/cosmetics/src/commonMain/kotlin/gg/essential/serialization/SnakeAsUpperCaseSerializer.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer + +open class SnakeAsUpperCaseSerializer(val inner: KSerializer) : JsonTransformingSerializer(inner) { + override fun transformSerialize(element: JsonElement): JsonElement = + if (element is JsonPrimitive && element.isString) { + JsonPrimitive(element.content.uppercase()) + } else { + element + } + + override fun transformDeserialize(element: JsonElement): JsonElement = + if (element is JsonPrimitive && element.isString) { + JsonPrimitive(element.content.lowercase()) + } else { + element + } +} \ No newline at end of file diff --git a/cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/Instant.kt b/cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/Instant.kt new file mode 100644 index 0000000..dbe505c --- /dev/null +++ b/cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/Instant.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import java.time.Instant + +actual typealias Instant = Instant + +actual fun now(): Instant = Instant.now() +actual fun instant(epochMillis: Long): Instant = Instant.ofEpochMilli(epochMillis) + +actual fun instantFromIso8601(str: String): Instant = + Instant.parse(str) +actual fun instantToIso8601(instant: Instant): String = + instant.toString() diff --git a/cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/platformMath.kt b/cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/platformMath.kt new file mode 100644 index 0000000..4e14533 --- /dev/null +++ b/cosmetics/src/minecraftMain/kotlin/gg/essential/model/util/platformMath.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.model.util + +import java.security.MessageDigest +import java.util.* + +internal actual fun cbrt(value: Float): Float = Math.cbrt(value.toDouble()).toFloat() + +internal actual fun base64Encode(value: ByteArray): String = + Base64.getEncoder().encodeToString(value) +internal actual fun base64Decode(value: String): ByteArray = + Base64.getDecoder().decode(value) +internal actual fun md5Hex(value: ByteArray): String = + MessageDigest.getInstance("MD5").digest(value).joinToString("") { "%02x".format(it) } diff --git a/elementa/build.gradle.kts b/elementa/build.gradle.kts new file mode 100644 index 0000000..b3dca20 --- /dev/null +++ b/elementa/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.SyncToExternalRepoTask +import java.nio.file.Paths + +tasks.create("syncCommitsToElementa") { + targetRepoPath.set(Paths.get("${project.rootDir}/../Elementa")) + targetDirectories.set(listOf( + "unstable/statev2/src", + "unstable/layoutdsl/src" + )) + sourceDirectories.set(listOf( + "elementa/statev2/src", + "elementa/layoutdsl/src" + )) + replacements.set(listOf( + "elementa/statev2" to "unstable/statev2", + "elementa/layoutdsl" to "unstable/layoutdsl", + "gg/essential/gui/elementa" to "gg/essential/elementa", + "gg.essential.gui.elementa" to "gg.essential.elementa", + "gg/essential/gui" to "gg/essential/elementa", + "gg.essential.gui" to "gg.essential.elementa", + // remove accessed via reflection annotation + " @AccessedViaReflection(\"DelegatingStateBase\")" to "", + "import gg.essential.config.AccessedViaReflection" to "" + )) + +} \ No newline at end of file diff --git a/elementa/layoutdsl/build.gradle.kts b/elementa/layoutdsl/build.gradle.kts new file mode 100644 index 0000000..7299f86 --- /dev/null +++ b/elementa/layoutdsl/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.universalLibs +import gg.essential.gradle.util.KotlinVersion +import gg.essential.gradle.util.setJvmDefault + +plugins { + kotlin("jvm") + id("gg.essential.defaults") +} + +universalLibs() + +dependencies { + implementation(kotlin("stdlib-jdk8", KotlinVersion.minimal.stdlib)) + implementation(project(":feature-flags")) + api(project(":elementa:statev2")) +} + +// We need to use the compatibility mode on old versions because we used to use the old Kotlin defaults for those +// And while this isn't currently part of our ABI, once stuff migrates to Elementa, it will be, so we consider it now. +tasks.compileKotlin.setJvmDefault("all-compatibility") + +kotlin.jvmToolchain(8) + +tasks.compileKotlin { + kotlinOptions { + moduleName = "essential" + project.path.replace(':', '-').lowercase() + } +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/HollowUIContainer.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/HollowUIContainer.kt new file mode 100644 index 0000000..7b0f4bc --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/HollowUIContainer.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIContainer + +/** + * A UIContainer that does not return true for [isPointInside] unless + * any of the child are hovered + */ +open class HollowUIContainer : UIContainer() { + + override fun isPointInside(x: Float, y: Float): Boolean { + return children.any { + it.isPointInside(x, y) + } + } +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/ListState.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/ListState.kt new file mode 100644 index 0000000..42e829a --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/ListState.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.gui.elementa.state.v2.collections.TrackedList + +typealias AddListener = (index: Int, element: T) -> Unit +typealias SetListener = (index: Int, element: T, oldElement: T) -> Unit +typealias RemoveListener = AddListener +typealias ClearListener = (list: List) -> Unit + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("mutableListState(state)", "gg.essential.gui.elementa.state.v2.ListKt.mutableListState")) +class ListState(initialList: MutableList = mutableListOf()) : BasicState>(initialList) { + private val addListeners = Listeners>() + private val setListeners = Listeners>() + private val removeListeners = Listeners>() + private val clearListeners = Listeners>() + + fun onAdd(action: AddListener) = apply { + addListeners.add(action) + } + + fun onSet(action: SetListener) = apply { + setListeners.add(action) + } + + fun onRemove(action: RemoveListener) = apply { + removeListeners.add(action) + } + + fun onClear(action: ClearListener) = apply { + clearListeners.add(action) + } + + fun add(element: T) = apply { + add(get().size, element) + } + + fun add(index: Int, element: T) = apply { + get().add(index, element) + addListeners.forEach { it(index, element) } + } + + fun set(index: Int, element: T) = apply { + get().also { list -> + val oldValue = list[index] + if (element == oldValue) + return@also + list[index] = element + setListeners.forEach { it(index, element, oldValue) } + } + } + + fun remove(element: T) = apply { + removeAt(get().indexOf(element)) + } + + fun removeAt(index: Int) = apply { + get().also { list -> + val element = list.removeAt(index) + removeListeners.forEach { it(index, element) } + } + } + + fun clear() = apply { + val list = get() + val values = list.toList() + list.clear() + clearListeners.forEach { it(values) } + } + + fun onElementAddedOrPresent(action: (element: T) -> Unit) = apply { + onElementAdded(action) + get().forEach(action) + } + + fun onElementAdded(action: (element: T) -> Unit) = apply { + onAdd { _, element -> + action(element) + } + + onSet { _, element, _ -> + action(element) + } + } + + fun onElementRemoved(action: (element: T) -> Unit) = apply { + onSet { _, _, oldElement -> + action(oldElement) + } + + onRemove { _, element -> + action(element) + } + + onClear { + it.forEach(action) + } + } + + fun onElementAddedOrRemoved(action: (element: T) -> Unit) = apply { + onElementAdded(action) + onElementRemoved(action) + } + + fun onElementAddedOrRemovedOrPresent(action: (element: T) -> Unit) = apply { + onElementAddedOrRemoved(action) + get().forEach(action) + } + + operator fun contains(element: T) = element in get() + + fun reduce(mapper: (List) -> U) = MappedListState(this, mapper) + + companion object { + fun from(state: State>): ListState { + val listState = ListState() + state.onSetValueAndNow { newList -> + for (change in TrackedList.Change.estimate(listState.get(), newList)) { + when (change) { + is TrackedList.Clear -> listState.clear() + is TrackedList.Add -> listState.add(change.element.index, change.element.value) + is TrackedList.Remove -> listState.removeAt(change.element.index) + } + } + } + return listState + } + } +} + +fun ListState.mapList(mapper: (List) -> List): ListState = ListState.from(reduce(mapper)) + +// TODO: all of these are quite inefficient, might make sense to implement some as efficient primitives instead + +fun ListState.filter(filter: (T) -> Boolean) = mapList { it.filter(filter) } + +fun ListState.map(mapper: (T) -> U) = mapList { it.map(mapper) } + +fun ListState.zip(otherState: State, transform: (T, U) -> V) = + ListState.from(reduce { it.toList() }.zip(otherState).map { (list, other) -> list.map { transform(it, other) } }) + +fun ListState.zip(otherList: ListState, transform: (T, U) -> V) = + ListState.from(reduce { it.toList() }.zip(otherList.reduce { it.toList() }).map { (a, b) -> a.zip(b, transform) }) + +fun ListState.mapNotNull(mapper: (T) -> U?) = mapList { it.mapNotNull(mapper) } + +fun ListState.filterNotNull() = mapList { it.filterNotNull() } + +inline fun ListState<*>.filterIsInstance() = mapList { it.filterIsInstance() } + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("state.map", "gg.essential.gui.elementa.state.v2.combinators.StateKt.map")) +class MappedListState(state: ListState, mapper: (List) -> U) : BasicState(mapper(state.get())) { + init { + state.onAdd { _, _ -> + set(mapper(state.get())) + } + + state.onSet { _, _, _ -> + set(mapper(state.get())) + } + + state.onRemove { _, _ -> + set(mapper(state.get())) + } + + state.onClear { + set(mapper(emptyList())) + } + } +} + +/** A mutable list of listeners that is safe to extend while being iterated. */ +private class Listeners { + private val active = mutableListOf() + private val new = mutableListOf() + + fun add(listener: T) { + new.add(listener) + } + + fun forEach(caller: (T) -> Unit) { + if (new.isNotEmpty()) { + active.addAll(new) + new.clear() + } + active.forEach(caller) + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/Spacer.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/Spacer.kt new file mode 100644 index 0000000..ee614ee --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/Spacer.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.constraints.HeightConstraint +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.dsl.constrain +import gg.essential.elementa.dsl.pixels + +/** + * A simple UIContainer where you can specify [width], [height], or both. + * + * If only [width] is specified, X-axis will be constrained to [SiblingConstraint]. + * + * If only [height] is specified, Y-axis will be constrained to [SiblingConstraint]. + */ +class Spacer(width: WidthConstraint = 0.pixels, height: HeightConstraint = 0.pixels) : HollowUIContainer() { + constructor(width: Float, _desc: Int = 0) : this(width = width.pixels) { setX(SiblingConstraint()) } + constructor(height: Float, _desc: Short = 0) : this(height = height.pixels) { setY(SiblingConstraint()) } + constructor(width: Float, height: Float) : this(width = width.pixels, height = height.pixels) + + init { + constrain { + this.width = width + this.height = height + } + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/AlternateConstraint.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/AlternateConstraint.kt new file mode 100644 index 0000000..94f4855 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/AlternateConstraint.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.SizeConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor + +/** + * Constraint which tries to evaluate the given constraint but falls back to another constraint if the first constraint + * results in a circular constraint chain. + * + * You probably shouldn't use this. With great power comes great responsibility. + * Be sure to fully understand how this works and interacts with other constraints before using, otherwise you may see + * undefined behavior such as unstable results, stack overflow, etc. if any of the involved constraints are not pure + * or more generally not safe to evaluate recursively (this one for example isn't, so don't ever use multiple). + */ +class AlternateConstraint( + val primary: SizeConstraint, + val fallback: SizeConstraint, +) : SizeConstraint { + override var recalculate: Boolean = true + override var cachedValue: Float = 0f + override var constrainTo: UIComponent? + get() = null + set(value) = throw UnsupportedOperationException() + + private var tryingPrimary = false + private var primaryWasRecursive = false + + override fun animationFrame() { + primary.animationFrame() + fallback.animationFrame() + + super.animationFrame() + } + + private inline fun eval(eval: (SizeConstraint) -> Float): Float { + if (!tryingPrimary) { + tryingPrimary = true + try { + primaryWasRecursive = false + val value = eval(primary) + if (!primaryWasRecursive) { + return value + } + } finally { + tryingPrimary = false + } + } else { + primaryWasRecursive = true + } + return eval(fallback) + } + + + override fun getWidthImpl(component: UIComponent): Float = + eval { it.getWidth(component) } + + override fun getHeightImpl(component: UIComponent): Float = + eval { it.getHeight(component) } + + override fun getRadiusImpl(component: UIComponent): Float = + eval { it.getRadius(component) } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {} +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/CenterPixelConstraint.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/CenterPixelConstraint.kt new file mode 100644 index 0000000..984a6b1 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/CenterPixelConstraint.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.PositionConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import kotlin.math.ceil +import kotlin.math.floor + +/** Centers the component to whole pixels, rounding down unless [roundUp] is true */ +class CenterPixelConstraint(private val roundUp: Boolean = false) : PositionConstraint { + + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getXPositionImpl(component: UIComponent): Float { + val parent = constrainTo ?: component.parent + + val center = if (component.isPositionCenter()) { + parent.getWidth() / 2 + } else { + parent.getWidth() / 2 - component.getWidth() / 2 + } + + return parent.getLeft() + if (roundUp) ceil(center) else floor(center) + } + + override fun getYPositionImpl(component: UIComponent): Float { + val parent = constrainTo ?: component.parent + + val center = if (component.isPositionCenter()) { + parent.getHeight() / 2 + } else { + parent.getHeight() / 2 - component.getHeight() / 2 + } + + return parent.getTop() + if (roundUp) ceil(center) else floor(center) + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {} +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/FillConstraintIncludingPadding.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/FillConstraintIncludingPadding.kt new file mode 100644 index 0000000..f86dac9 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/FillConstraintIncludingPadding.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.PaddingConstraint +import gg.essential.elementa.constraints.SizeConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor + +/** + * @see FillConstraint but includes padding in width and height calculations to correctly position the component + */ +class FillConstraintIncludingPadding @JvmOverloads constructor(private val useSiblings: Boolean = true) : SizeConstraint { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getWidthImpl(component: UIComponent): Float { + val target = constrainTo ?: component.parent + + return if (useSiblings) { + target.getWidth() - target.children.sumOf { + val width = if (it == component) 0 else it.getWidth() + width.toDouble() + ((it.constraints.x as? PaddingConstraint)?.getHorizontalPadding(it) ?: 0f).toDouble() + }.toFloat() + } else target.getRight() - component.getLeft() + ((target.constraints.x as? PaddingConstraint)?.getHorizontalPadding(target) ?: 0f) + } + + override fun getHeightImpl(component: UIComponent): Float { + val target = constrainTo ?: component.parent + + return if (useSiblings) { + target.getHeight() - target.children.sumOf { + val height = if (it == component) 0 else it.getHeight() + height.toDouble() + ((it.constraints.y as? PaddingConstraint)?.getVerticalPadding(it) ?: 0f).toDouble() + }.toFloat() + } else target.getBottom() - component.getTop() + ((target.constraints.y as? PaddingConstraint)?.getVerticalPadding(target) ?: 0f) + } + + override fun getRadiusImpl(component: UIComponent): Float { + val target = constrainTo ?: component.parent + + return if (useSiblings) { + target.getRadius() - target.children.filter { it != component }.sumOf { + it.getRadius().toDouble() + }.toFloat() + } else (target.getRadius() - component.getLeft()) / 2f + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + when (type) { + ConstraintType.WIDTH -> { + visitor.visitParent(ConstraintType.WIDTH) + + if (useSiblings) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + val numParentChildren = visitor.component.parent.children.size + + for (i in 0 until numParentChildren) { + if (indexInParent != i) + visitor.visitSibling(ConstraintType.WIDTH, i) + } + } else { + visitor.visitParent(ConstraintType.X) + visitor.visitSelf(ConstraintType.X) + } + } + ConstraintType.HEIGHT -> { + visitor.visitParent(ConstraintType.HEIGHT) + + if (useSiblings) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + val numParentChildren = visitor.component.parent.children.size + + for (i in 0 until numParentChildren) { + if (indexInParent != i) + visitor.visitSibling(ConstraintType.HEIGHT, i) + } + } else { + visitor.visitParent(ConstraintType.Y) + visitor.visitSelf(ConstraintType.Y) + } + } + ConstraintType.RADIUS -> { + visitor.visitParent(ConstraintType.RADIUS) + + if (useSiblings) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + val numParentChildren = visitor.component.parent.children.size + + for (i in 0 until numParentChildren) { + if (indexInParent != i) + visitor.visitSibling(ConstraintType.RADIUS, i) + } + } else { + visitor.visitSelf(ConstraintType.X) + } + } + else -> throw IllegalArgumentException(type.prettyName) + } + } +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/SpacedCramSiblingConstraint.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/SpacedCramSiblingConstraint.kt new file mode 100644 index 0000000..f8a8c4a --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/constraints/SpacedCramSiblingConstraint.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor + +/** + * Note: All items are assumed to be same width + */ +class SpacedCramSiblingConstraint( + private val minSeparation: WidthConstraint, + private val margin: WidthConstraint, + private val verticalSeparation: WidthConstraint? = null, +) : + SiblingConstraint() { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getXPositionImpl(component: UIComponent): Float { + val index = component.parent.children.indexOf(component) + + val marginPixels = margin.getWidth(component).toInt() + val minSeparationPixels = minSeparation.getWidth(component).toInt() + val totalWidth = component.parent.getWidth() - marginPixels * 2 + val itemWidth = component.getWidth() + val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt() + if (itemsPerRow <= 1) { + return component.parent.getLeft() + ((totalWidth - itemWidth) / 2f) + } + if (index == 0) { + return component.parent.getLeft() + marginPixels + } + val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1) + val sibling = component.parent.children[index - 1] + if (sibling.getRight() + component.getWidth() + minSeparationPixels <= component.parent.getRight() + floatErrorMargin) { + return sibling.getRight() + itemSep + } + + return component.parent.getLeft() + marginPixels + } + + override fun getYPositionImpl(component: UIComponent): Float { + val index = component.parent.children.indexOf(component) + + val marginPixels = margin.getWidth(component).toInt() + if (index == 0) { + return component.parent.getTop() + marginPixels + } + + val minSeparationPixels = minSeparation.getWidth(component).toInt() + val totalWidth = component.parent.getWidth() - marginPixels * 2 + val itemWidth = component.getWidth() + val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt() + val sibling = component.parent.children[index - 1] + if (itemsPerRow <= 1) { + return sibling.getBottom() + minSeparationPixels + } + val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1) + + if (sibling.getRight() + component.getWidth() + minSeparationPixels <= component.parent.getRight() + floatErrorMargin) { + return sibling.getTop() + } else if (sibling.javaClass != component.javaClass) { + // FIXME This workaround is broken and should never have been added in the first place. Instead of mixing + // different components with SpacedCramSiblingConstraints, just put a wrapper component around the + // grid. + // Should be removed once the old CosmeticStudio is dead. + // If the previous item not a cosmetic option, position right after it so vertical padding + // can be made consistent. Otherwise, `itemSep` can vary and lead to inconsistent padding. + return sibling.getBottom() + } + val verticalSep = verticalSeparation?.getWidth(component.parent) + ?: itemSep.coerceAtLeast(minSeparationPixels.toFloat()) + return getLowestPoint(sibling, component.parent, index) + verticalSep + } + + // This allows ChildBasedSizeConstraint to function for the parent height by emitting negative padding for + // items that are layed out in-line. + // Note: For simplicity this assumes all items are the same size, both horizontally (as the constraint as a whole + // already assumes) but also vertically. + // It also does not support margin as that's just unnecessary complexity (just add a wrapper if you need it). + override fun getVerticalPadding(component: UIComponent): Float { + val index = component.parent.children.indexOf(component) + if (index == 0) { + return 0f + } + + val minSeparationPixels = minSeparation.getWidth(component).toInt() + val totalWidth = component.parent.getWidth() + val itemWidth = component.getWidth() + val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt() + if (itemsPerRow <= 1) { + return minSeparationPixels.toFloat() + } + val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1) + if (index % itemsPerRow == 0) { + return verticalSeparation?.getWidth(component.parent) + ?: itemSep.coerceAtLeast(minSeparationPixels.toFloat()) + } + return -component.getHeight() + } + + override fun to(component: UIComponent) = apply { + throw UnsupportedOperationException("Constraint.to(UIComponent) is not available in this context!") + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + + when (type) { + ConstraintType.X -> { + if (indexInParent == 0) { + visitor.visitParent(ConstraintType.X) + return + } + + visitor.visitSelf(ConstraintType.WIDTH) + visitor.visitSibling(ConstraintType.X, indexInParent - 1) + visitor.visitSibling(ConstraintType.WIDTH, indexInParent - 1) + visitor.visitParent(ConstraintType.WIDTH) + visitor.visitParent(ConstraintType.X) + } + ConstraintType.Y -> { + if (indexInParent == 0) { + visitor.visitParent(ConstraintType.Y) + return + } + + visitor.visitSelf(ConstraintType.WIDTH) + visitor.visitSibling(ConstraintType.X, indexInParent - 1) + visitor.visitSibling(ConstraintType.WIDTH, indexInParent - 1) + visitor.visitParent(ConstraintType.WIDTH) + visitor.visitParent(ConstraintType.X) + } + else -> throw IllegalArgumentException(type.prettyName) + } + } + + companion object { + private const val floatErrorMargin = 0.001f + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/stateExtensions.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/stateExtensions.kt new file mode 100644 index 0000000..19c9c14 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/common/stateExtensions.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.state.State +import gg.essential.elementa.state.v2.ReferenceHolder + +fun State.onSetValueAndNow(listener: (T) -> Unit) = onSetValue(listener).also { listener(get()) } + +@Deprecated("See `State.onSetValue`. Use `stateBy`/`effect` instead.") +fun gg.essential.gui.elementa.state.v2.State.onSetValueAndNow(owner: ReferenceHolder, listener: (T) -> Unit) = + onSetValue(owner, listener).also { listener(get()) } + +operator fun State.not() = map { !it } \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/README.md b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/README.md new file mode 100644 index 0000000..0b5c1b0 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/README.md @@ -0,0 +1,1098 @@ +# Layout DSL + +The Layout DSL provides a high-level DSL to declare the overall structure and layout of Elementa components or entire +screens. + +Note: This document does not cover the `State` API (in part because State V2 is still being worked on). + The Layout DSL does make heavy use of it though (at least for anything dynamic). Until someone writes a guide for + it, here's a one paragraph primer: + Most of the high-level state of your GUI lives in multiple `MutableState` instances, one per factum. You then + `map` or `zip` these to derive various other `State`s (like the text in a specific gui label) from them. Most gui + components as well as the dynamic parts of the Layout DSL can accept `State`s and will then automatically update + whenever you change any of your `MutableState`s. There's also special support for `List`s in states (though V1's + special case for this, ListState, has various footguns), with V2 also for `Set`s in states. And the last thing + that should be mentioned is `stateBy` for when `map` and `zip` don't cut it (may actually become the new standard + for V2, still to be decided). + For something more detailed, take a look at the public members of the main file (and potentially other files) of + State V2 [1]. + V1 is similar but more messy, see [2] for an overview of differences. + Though you will still need to look at various existing uses (outside of Elementa where backwards-compatibility + complicates everything; would recommend the Wardrobe as it's the most recently written one and also uses the + Layout DSL) to see something real. + +[1]: https://github.com/EssentialGG/Elementa/blob/feature/state-v2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +[2]: https://github.com/EssentialGG/Elementa/pull/88#issue-1347835067 + +## Motivation + +This section explains the main problem(s) the Layout DSL was meant to solve by starting from a regular, old Elementa +example and gradually transforming it to use the Layout DSL instead. +This assumes you have at least a rough idea of how Elementa components and constraints work. + +The main issue the Layout DSL tries to solve is that in a regular Elementa screen, which consists of many sub-components +are that usually all declared in a field of the screen class, it is difficult to grasp how all these components relate +to each other without carefully following all `childOf` calls while also keeping an eye on all the constraints at all +times. +And the constraints part is not to be underestimated because the way Elementa constraints are currently declared does +not make it particularly easy to understand the layout of a particular component's children from just glancing at one +child. + +Consider for example this relatively simple screen that's just two equally-sized boxes (as big as possible with a fixed +padding) next to each other, the left has a centered text that reads Left, the right one is split vertically with the +center of the top half saying Top and the bottom half saying Bottom: +``` +|-----------------------------| +| | +| |----------| |----------| | +| | | | | | +| | | | TOP | | +| | LEFT | | | | +| | | | BOTTOM | | +| | | | | | +| |----------| |----------| | +| | +|-----------------------------| +``` + +The regular Elementa code for this would look something like this: +```kotlin +val window: Window = WindowScreen() + +// This extra wrapper may seem redundant here, the reason we have it will become clear later +val wrapper by UIContainer().constrain { + width = 100.percent + height = 100.percent +} childOf window + +val content by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 100.percent - 2.pixels + height = 100.percent - 2.pixels +} childOf wrapper + +val left by UIContainer().constrain { + width = 50.percent - 1.5.pixels + height = 100.percent +} childOf content + +val right by UIContainer().constrain { + x = 0.pixels(alignOpposite = true) + width = 50.percent - 1.5.pixels + height = 100.percent +} childOf content + +val leftText by UIText("Left").constrain { + x = CenterConstraint() + y = CenterConstraint() +} childOf left + +val top by UIContainer().constrain { + width = 100.percent + height = 50.percent +} childOf right + +val bottom by UIContainer().constrain { + y = 0.pixels(alignOpposite = true) + width = 100.percent + height = 50.percent +} childOf right + +val topText by UIText("Top").constrain { + x = CenterConstraint() + y = CenterConstraint() +} childOf top + +val bottomText by UIText("Bottom").constrain { + x = CenterConstraint() + y = CenterConstraint() +} childOf bottom +``` + +And this kind of code is **extremely** common since almost everything in most UIs is hierarchical. +But, without the ascii sketch above, it's unreasonably difficult to tell what this will actually look like until you +run it (or, with quite some effort, mentally evaluate it). + +It shouldn't be hard to imagine how bad this can get with more complex layouts. +It gets even worse once you start making some things dynamic because then you really need to go searching to find the +parent/children. +And reading the constraints can become quite difficult too because Elementa does not at all force you to actually use +additional wrapper components to define the layout, you could (and frequently it's convenient in the short term) totally +just define the three text components and give them highly complex constraints which compute the same thing. + +In the simplest case, layout dsl can at least allow you to more easily understand the parent-child relations. +For this first version, we effectively keep everything from above except for the `childOf` calls which are now handled +by the layout dsl: +```kotlin +window.layout { + wrapper { + content { + left { + leftText() + } + right { + top { + topText() + } + bottom { + bottomText() + } + } + } + } +} +``` +With that, it is immediately clear now that the top and bottom parts are children of the right side only. +But, if we did not name our components by their direction, it would still be difficult to tell whether things are layed +out vertically, horizontally or some other way. Additionally, things like size and alignment also still require you to +look at the component definitions. + +This is where `Modifier`s come in. A modifier expresses a set of configurations/modifications that one wishes to apply +to a given component. There are modifiers for a variety of things like position, size, color, effects, callbacks, etc. +Modifiers can be chained together so you get a single modifier that applies multiple modifications. +There even exist higher-order modifiers that e.g. apply a given modifier only while the component is being hovered. + +For now, let's use only the basic ones to exactly replicate the above example, but this time we can also remove the +`constrain` blocks from the original code: +```kotlin +val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight() +window.layout { + wrapper(Modifier.fillParent()) { + content(Modifier.alignBoth(Alignment.Center).fillParent(padding = 1f)) { + left(Modifier.alignHorizontal(Alignment.Start).then(halfWidth)) { + leftText(Modifier.alignBoth(Alignment.Center)) + } + right(Modifier.alignHorizontal(Alignment.End).then(halfWidth)) { + top(Modifier.alignHorizontal(Alignment.Start).fillWidth().fillHeight(0.5f)) { + topText(Modifier.alignBoth(Alignment.Center)) + } + bottom(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + bottomText(Modifier.alignBoth(Alignment.Center)) + } + } + } + } +} +``` + +You may notice that while some of these map relatively directly on existing constraints (e.g. +`fillWidth(fraction, padding)` is just `fraction.percent - (padding * 2).pixels`), there are also plenty higher-level +modifiers (e.g. `fillParent` which is both `fillWidth` as well as `fillHeight`) to reduce repetition and make it easier +to understand what a modifier does at first glance. +There are also modifiers that let you set constraints directly (`BasicXModifier`) but these exist only as an escape +hatch and you should ideally never need them. + +Ok, so the above is definitely more compact than the original code, and you can kind of tell the general layout if you +look carefully at the modifiers. But we can still do **a lot** better. + +For starters, all that is left in the fields at this point are the constructor calls, so it might be tempting to inline +them. And generally there's nothing wrong with this as long as they really are as simple as in the example. +If there's still a lot of component configuration left, for example click handlers, then you'll usually want to keep the +fields as to not blow up the DSL block (remember: it is meant to show the layout, not every last details; and it is +supposed to be easy to grasp as a whole, a 50 line click handler in the middle makes that a lot harder). + +```kotlin +// With fields: +bottom(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + bottomText(Modifier.alignBoth(Alignment.Center)) +} +// Constructors inlined: +UIComponent()(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + UIText("Bottom")(Modifier.alignBoth(Alignment.Center)) +} +// Because text and simple containers are quite common, there exist `box` and `text` methods which will create the +// components with the given modifiers. +box(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + text("Bottom", modifier = Modifier.alignBoth(Alignment.Center)) +} +``` + +Next, notice how there's quite a lot of `align(Center)`? +That's actually quite common and arguably because Elementa has bad default constraints. +Yes, `0.pixels` can make sense some times, but usually, if it is the only child and there is wiggle room in the parent, +you want your components to be centered, you don't want everything slanted to the left. + +To remedy this, any children of `box` will automatically be centered by default. You can still overwrite the positioning +by explicitly specifying an alignment as above, but the default is already what you want in quite a lot of cases. + +The default for size in Elementa is even worse. You practically never want your component to be `0.pixels` in size, yet +that's what you get by default. +Layout DSL improves that as well: The default size of a `box` is `ChildBasedSizeConstraint()`. It doesn't come up in our +example because it is sized fully top-down but for components that are sized bottom-up, this is a much better default +than the useless `0.pixels`. + +Applying this to our example, we get: +```kotlin +val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight() +window.layout { + box(Modifier.fillParent()) { + box(Modifier.fillParent(padding = 1f)) { + box(Modifier.alignHorizontal(Alignment.Start).then(halfWidth)) { + text("Left") + } + box(Modifier.alignHorizontal(Alignment.End).then(halfWidth)) { + box(Modifier.alignHorizontal(Alignment.Start).fillWidth().fillHeight(0.5f)) { + text("Top") + } + box(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + text("Bottom") + } + } + } + } +} +``` + +Note: We cannot, at this point, get rid of an `alignBoth` on the outer-most box because its parent is a `Window`, not + another `box`. This case doesn't actually happen very often in practice because it only happens on the outermost + Layout DSL layer, and if you're building a component to be used by other code, then that other code is usually the + one that specifies the position for you. + This is why we introduced the extra `wrapper` component in our original example, with it being full-size, we don't + need to align it. And more importantly, everything inside of it can fully use the Layout DSL with no distractions. + +The final two observations: There's still quite a lot of `align` happening, and unlike the field names `box` doesn't +really tell us anything about the relative positioning of the components, we have to look at the specific `align` calls. + +But there's no reason we can't introduce more methods like `box` with more meaningful names and more defaults. The two +common builtin ones are `row` and `column`: + +```kotlin +val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight() +window.layout { + box(Modifier.fillParent()) { + row(Modifier.fillParent(padding = 1f), Arrangement.SpaceBetween) { + box(halfWidth) { + text("Left") + } + column(halfWidth) { + box(Modifier.fillWidth().fillHeight(0.5f)) { + text("Top") + } + box(Modifier.fillWidth().fillHeight(0.5f)) { + text("Bottom") + } + } + } + } +} +``` + +`row` and `column`, unlike `box`, use a new `Arrangement`-based layout system on their primary axis and the already +introduced `Alignment`s in another optional argument as the default for their secondary axis (defaulting to Center if +not specified). + +They also have different default sizes, their primary axis being sized by the same `Arrangement` system with their +secondary axis sized by a `ChildBasedMaxSizeConstraint` (i.e. a `row` is as high as its highest child and as wide as all +its children together, plus some additional spacing depending on the arrangement). + +## Usage + +### LayoutScope + +The Layout DSL may be used to lay out the children of any Elementa component. +To use it, simply call the `layout` extension function on the component. + +The `layout` function takes an optional `Modifier` to be applied to the component itself as well as a block which, via +its receiver, has access to a `LayoutScope` instance through which it can add children to the component: +```kotlin +val myComponent = UIContainer() +val myChild = UIContainer() +val myInnerChild = UIContainer() + +myComponent.layout(Modifier.width(100).height(20)) { + // Adds `myChild` as a child of `myComponent` + invoke(myChild) + // Or, because it's actually an extension function on UIComponent, one could also call it like this: + myChild.invoke() + // The name may seem a bit odd, but that's because it's also an operator function, + // so the normal way to call it is actually just: + myChild() + + // This call may also receive a Modifier to be applied to the child as well as a block that opens another + // `LayoutScope`, this time for the child: + myChild(Modifier.fillParent()) { + // Adds `myInnerChild` as a child of `myChild` + myInnerChild() + } + + // But `myChild` doesn't have to be declared in a variable outside, it could also be declared inline, though + // this is usually discouraged if it's more than just a simple constructor call: + UIContainer()() + // Note the double `()`: the first one is the constructor `UIContainer` call, the second is the call to `invoke` + // that adds it as a child and can receive a Modifier and a block that opens another layout scope. +} +``` + +### Modifier + +A `Modifier` expresses a set of configurations/modifications that one wishes to apply to a given component. +There are modifiers for a variety of things like position, size, color, effects, callbacks, etc. + +One modifier, unlike a `Constraint`, is also explicitly meant to be re-usable on multiple components, such that a more +complex modifier can be build once, stored in a variable and then re-used for multiple components. + +#### DSL + +Modifiers have their own mini-DSL that allows them to be easily composed. + +You can get an empty modifier that does nothing with simply `Modifier` (syntactically, that's the companion object of +thi `Modifier` interface). You can then call various extension methods on this `Modifier` to add extra modifications +that should happen, like `Modifier.width(100).height(20)`. + +So with most modifiers, you don't actually get a `Modifier` instance directly, you merely get a constructor extension +method to tag the modifier onto your existing modifier because that's usually more convenient. + +When you do however have two modifiers instances that you want to chain together, you can do so via the `then` method, +like `modifierA.then(modifierB)`. The resulting modifier will first apply all modifications from `modifierA` and then +all modifications from `modifierB`. You can also call `then` as an infix operator, like `modifierA then modifierB`. + +Most of the extension methods should simply be defined as `fun Modifier.something() = this then ...`. + +#### Sizing modifiers + +The two main ways to size component hierarchies is either top-down or bottom-up, i.e. either the parent component has a +fixed size (such as the screen) and its children try to grow as big as there is space, or the child has a fixed size and +the parent tries to shrink as far as possible while still containing the child. + +Usually any given screen will make use of both methods and they will meet at some point in the middle, e.g. a button is +sized as big as the text it contains plus some padding but the container in which the button resides in is as big as +possible (and the button may for example be centered within it). + +The point at which these meet is also frequently different depending on the axis. E.g. a button may be as high as +required by its text but as wide as its parent permits. + +Elementa does not currently allow for both approaches to be applied to the same component at the same time. + +##### Fixed size + +The `width`/`height` modifiers will assign a fixed size in pixels to a component. + +They do have overloads that accept another component and copy its size, though these are rarely used. + +Not quite fixed but dependent on neither parent nor children, the `widthAspect`/`heightAspect` modifiers will set the +width/height of a component to a multiple of its height/width. + +##### Top-down + +The single most common modifier for trying to grow a component as big as its parent permits is `fillParent`. +Its first argument is the fraction it should attempt to grow to (e.g. `0.5` would make it grow to 50% of the parent's +size). +Its second argument is a fixed padding in pixels that it should maintain on each side. + +E.g. if the parent is 10 pixels wide, then with `Modifier.fillParent(0.5, 1)` the child will be `0.5 * 10 - 1 * 2 = 3` + +Similarly `fillWidth` and `fillHeight` can be used to configure a single axis. + +Another, less commonly used but still important modifier is `fillRemainingWidth`/`fillRemainingHeight` which can only +be used by a single child and will cause that child to take up any remaining space in the parent. + +##### Bottom-up + +The `childBasedWidth`/`childBasedHeight` will size the component to match the total size of its children along the +respective axis. +The `childBasedMaxWidth`/`childBasedMaxHeight` will size the component to match the biggest of its children along the +respective axis. + +Both of the above accept an optional `padding` parameter which will add an extra, fixed amount of pixels for each side +to the width/height of this component. That is, the padding is between this component and all its children. Not between +any two children individually: `this.width = padding + sum(child.width) + padding`. + +It should be noted that, unless you're using the padding parameter, you usually don't need to explicitly use any of +these because the common `box`, `row`, and `column` containers use them by default. + +#### Alignment modifiers + +If there is more spaces in your parent than your child needs, you may need to specify how it should be aligned inside +its parent. The `alignHorizontal`/`alignVertical` modifiers will set the `x`/`y` position of the component according to +the given `Alignment`. The `alignBoth` modifier will use the same alignment for both axes. + +Note that the common `box` container, as well as the secondary axes of `row` and `column` already use +`Aligntment.Center` by default for all their children, so often you do not need to explicitly set the alignment. +The primary axes of `row` and `column` use the `Arrangement` system for positioning, so Alignment does not apply there. + +The three most common alignments are `Start`, `Center` and `End`. + +`Start` and `End` can additionally accept an optional `padding` parameter. + +`Center` puts the component in the center of its parent aligned to the nearest full MC pixel in the context of its +parent. E.g. if the component is 2 in height and its parent is 5 in height, then this will place the component at one +pixel distance from the top of its parent, and two pixels from the bottom. +This is usually preferred design-wise. +You can get the true center (1.5 in above example) with the `TrueCenter` alignment. + +#### Constraint modifiers + +There exist `BasicXModifier` where `X` may be replaced with any of the standard constraint types which simply set the +given constraint on the component. + +Note that usually you shouldn't need these, there's usually a more high-level modifier or container you can use instead. +These only exist as an escape hatch. + +#### Conditional modifiers + +Modifiers can be dynamically applied and reverted in response to the value of a `State`. + +The main primitive is an overload of `then` that takes a `State` as its argument and applies the modifier in +the State, reverting it and re-applying whenever the State changes. + +For the special case of `State` a `whenTrue` modifier exists that applies a given modifier only while the given +state is `true` (and optionally applies a different modifier while it isn't). + +#### Event modifiers + +There exist modifiers that register a callback on the component for various events such as mouse enter, mouse leave, +left click, etc. + +Note that you usually don't want to use the mouse enter/leave callbacks because they are rather coarse, see the Hovering +section instead. + +#### Custom modifiers + +So what exactly is a modifier? How would I define my own? +Simply put, it's a function that can apply a change to a component, and returns another function to undo the change +again: +```kotlin +interface Modifier { + fun applyToComponent(component: UIComponent): () -> Unit +} +``` + +If it is not possible to cleanly undo the change, or if it is difficult to implement and highly unlikely to ever be +used, the undo function may simply throw an `UnsupportedOperationException`. + +There even exists an overload of `then` that takes such a function directly, so you can easily define your own modifier +extensions like this: +```kotlin +fun Modifier.something() = this then { + // The component is passed as the receiver, so you can simply call its methods + val orgConstraint = constraints.x + constrain { + // Do keep in mind that modifiers are supposed to be re-usable, so you need to create a new constraint here + // every time, you cannot for example re-use a single constraint passed via arguments. + // That's why the BasicXModifier takes a constraint factory as its argument rather than a single constraint. + x = 10.pixels + } + + // And finally return a function that will clean up your change (or throw a NotImplementedError if your modifier + // can/does not support that) + { + constrain { + x = orgConstraint + } + } +} +``` + +### Containers + +While not strictly enforced by Elementa, a component tree is generally built from a whole bunch of arbitrarily nested +containers (tree nodes) with content components (tree leafs) at the bottom. + +Most UIs can be broken down into just three types of fundamental container types: +- Simple `row`s that contain multiple children left to right +- Simple `column`s that contain multiple children top to bottom +- Plain `box`es that contain one or more children in no particular layout + +Due to how common these are, the Layout DSL has dedicated methods to easily create them and most importantly apply their +layouts in an intuitive way. + +```kotlin +window.layout { + box(Modifier.width(500).height(500)) { + column { + row { + text("top left") + text("top right") + } + text("*second row*") + } + } +} +``` + +If for some reason you need to refer to one of these at a later point, you can store their return value in a local +variable or field: +```kotlin +val wrapper: UIComponent +val content: UIComponent +window.layout { + wrapper = box(Modifier.width(500).height(500)) { + content = column { + // ... + } + } +} +``` + +#### box + +A `box` is a plain container, fairly similar to `UIContainer`. +It does however have different defaults for its size as well as the position of all its children, and it functions as +expected with the `color` modifier (it's more like `UIBlock` in that respect). + +By default a `box` will try to match the size of its children. Or rather, child, because if there are multiple things +that should go into the box, it's usually better to wrap those with either `row` or `column`. +`box` is usually only used to add padding or a fixed size and/or background color. + +The default position for children of `box` is `Alignment.Center`. + +E.g. a button 100x20 with a 1px outline: +```kotlin +box(Modifier.width(100f).height(20f).color(outlineColor)) { + box(Modifier.fillParent(padding = 1f).color(backgroundColor)) { + text(label) + } +} +``` + +#### row + +A `row` is a container fairly similar to `box` except that it is meant to handle multiple children arranged horizontally +in some way. +As such, it can accept not just a modifier but also a horizontal `Arrangement` and a default vertical `Alignment`. + +By default a `row` will try to match the height of its tallest child and the width of all its children summed up plus +any padding as specified by the arrangement. +That is, it will try to be as small as it can be, just like all the other common containers. + +If a child is less tall than its parent row, i.e. if it could float up and down, it will be positioned vertically +according to the passed `Alignment` (unless a different alignment was applied to the specific child directly). + +The way surplus space is distributed on the main, horizontal axis is determined by the `Arrangement`. +See the Arrangement section for more information. + +Note: Currently the default Alignment is `spacedBy(0, FloatPosition.Left)`, this may be changed to + `FloatPosition.Center` in the future. + +#### column + +See `row` and swap horizontal and vertical. + +#### flowContainer + +A `flowContainer` acts similar to a `row` except that it expects to be limited in width and will start new rows when +no more items fit into the current one. + +The `minSeparation` argument determines the minimal horizontal padding between any two children in the same row. +The `verticalSeparation` argument determines the vertical padding between rows. + +Note: This container is likely subject to change in the future because its design wasn't very thought out and it + currently only serves a single use-case. + In particular it currently suffers from the following assumptions: + +- it assumes that all children are the same size +- it assumes `Arrangement.SpaceBetween` for any surplus space +- it assumes the row as its primary axis, there's no way to change it to fill columns first + +#### scrollable + +A `scrollable` is like a `box` with a single child which can be scrolled vertically and/or horizontally if it is larger +than the scrollable on the given axis. +Content that ends up outside the bounds of the scrollable will not be rendered. + +If the child is smaller than the parent, it will by default be centered (just like `box`). +If you wish to have multiple children, it is recommended that you wrap them in a `column`, `row` or other container +according to your needs as there is no way to change the default arrangement of the scrollable. + +```kotlin +scrollable(Modifier.fillHeight(), vertictal = true) { + column { + text("top") + spacer(height = 1000) + text("bottom") + } +} +``` + +Note: This component has not yet seen much use and may still need some refinement. + +Note: The `scrollable` method returns an instance of `ScrollComponent`. This may change in the future and you are + advised to refrain from using most of its functionality as it is very overloaded and will often act different + than what you would expect. + Generally the only things that are safe to use are the scroll events and `scrollTo`-type methods. + +#### lazyBox + +Lazily initializes the inner scope by first only placing a `box` as described by the given `modifier` without any +children and only initializing the inner scope once that box has been rendered once. + +This should be a last reserve for initializing a large list of poorly optimized components, not a common shortcut to +"make it not lag". Properly profiling and fixing initialization performance issues should always be preferred. + +### Content + +Similar to the previous "Containers" section, while one could just declare all their components in a field or directly +in-line, some components are so common that more convenient shorthands exist. + +There's not really anything special about most of these, so they don't need much explanation: +- `text`: Creates single line of text (`EssentialUIText`) +- `wrappedText`: Creates text that wraps into multiple lines if there is not enough space in its parent (`EssentialUIWrappedText`) +- `icon`: Creates an icon with a shadow (`ShadowIcon`) + +#### spacer + +The `spacer` method creates simple, invisible, one-dimensional components. Their sole purpose is to take up a specific +amount of space at one specific place anywhere between/before/after regular components/containers. + +```kotlin +row { + spacer(width = 2f) + text("Hello") + spacer(width = 10f) + text("World") +} +// results in: | Hello World| +``` + +When to use `spacer` or `Arrangement` often depends more on the intend behind the layout than anything else. +If you just want some arbitrary amount of extra space somewhere, then `spacer` is probably want you want. +If you want there to be a symmetrical padding inside your component, then maybe `spacer` isn't the best for the job. + +If you have non-symmetrical padding, frequently that can be broken down into a symmetrical part and an extra part (but +only do so if that makes from a layout point of view), and then both can be wrapped up into a `row` or `container` +depending on the axis you're working with. + +```kotlin +// V V one space each +// | a b c | +// ^ 7 spaces ^ 2 spaces +// could be written as: +row { + spacer(width = 7f) + row(Arrangement.spacedBy(1f)) { + text("a") + text("b") + text("c") + } + spacer(width = 2f) +} +// and depending on why you want the space to be there, that may be totally reasonable. +// But if parts of the space are meant as padding around the text, and the remainder is just to keep space from +// whatever is to the left of the row, then introducing another container may be preferable as now if we want to +// increase the padding around the content, we don't have to modify two magic numbers: +row { + spacer(width = 5f) + box(Modifier.childBasedWidth(padding = 2f)) { + row(Arrangement.spacedBy(1f)) { + text("a") + text("b") + text("c") + } + } +} +``` + +Frequently, introducing another layer, even if it is seemingly redundant based on what is drawn, does actually make more +sense than using spacers because it has semantic significance in the layout. + +The only pattern that should categorically be avoided is using spacer in a `row`/`column` that itself is using +`spacedBy` or a top-down layout with surplus space to contribute, except in the case where the spacer actually +represents an empty entry in the container. +For the above example, this would be: +```kotlin +// This does give the same result as above, but neither of the spacers represents anything tangible and the actual space +// before / after the text is different than what you would think after quickly skimming the code. +row(Arrangement.spacedBy(1f)) { + spacer(width = 6f) + text("a") + text("b") + text("c") + spacer(width = 1f) +} +``` + +#### scrollGradient + +The `scrollGradient` method adds a shadow-like gradient at the top and the bottom of a `scrollable`. +The gradient will fade in/out as you the scrollable is scrolled. That is, the top gradient won't be visible if it is +scrolled to the very top, and the bottom gradient will become invisible when it is scrolled to the very bottom. + +They will usually be added directly after the scroller in a shared box that matches the size of the scrollable: +```kotlin +box(Modifier.fillParent()) { + val scroller = scrollable(Modifier.fillParent(), vertictal = true) { + column { + text("top") + spacer(height = 1000) + text("bottom") + } + } + + val gradientHeight = Modifier.height(30) + scrollGradient(scroller, top = true, gradientHeight) + scrollGradient(scroller, top = false, gradientHeight) +} +``` + +Note: This component has not yet seen much use and may still need some refinement. + +#### Custom components + +##### Function components + +While the regular sub-class way of creating custom Elementa components can be used just fine with the Layout DSL, a +pattern that's ofter easier is to simply pull out certain parts of your Layout DSL tree into separate functions: + +```kotlin +fun LayoutScope.button(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + box(Modifier.width(100f).height(20f).color(Palette.buttonOutlineColor).onLeftClick(onClick).then(modifier)) { + box(Modifier.fillParent(padding = 1f).color(Palette.buttonBackgroundColor)) { + text(label) + } + } +} + +window.layout { + column(Arrangement.spacedBy(5f)) { + row(Arrangement.spacedBy(3f)) { + button("Yes", ::accept) + button("No", ::reject) + } + button("Cancel", ::cancel, Modifier.width(30f).height(10f)) + } +} +``` + +There is nothing magical about these functions. +They are just regular extension functions which have `LayoutScope` as their receiver and follow the general feel of +builtin content or container methods. + +They will frequently live either as inner functions (if the component is specific to one use-case) or as top-level +functions in their own file if they are reusable or big enough to warrant their own file. + +They usually have an optional Modifier argument (by convention it's usually the first optional argument) used to +configure the component (primarily its position). +Custom containers will also have an optional `block: LayoutScope.() -> Unit` argument (usually the final argument, so +it is eligible as the DSL-like trailing lambda) that configures the children. + +It is of course also possible for a function component to add multiple children in the passed scope, however this should +be used with care because the relative position / spacing of these children is not usually defined by the caller and so +the function by itself is ambiguous. And similarly the caller might expect the function to define a single child and by +then surprised that it throws off things because there's suddenly more children than expected. +So the only time this functionality may be useful is in local helper functions that are defined very close to their +usage (acting more like a template than a function component at that point); though even in these cases, often it makes +sense to add a wrapper container in the function component anyway. + +##### Class components + +Sometimes you need your custom component to be a full blown, regular Elementa component class. +But you can still use the Layout DSL to configure the inner working of such components: + +```kotlin +class Button(label: String, onClick: () -> Unit) : UIContainer() { + init { + layout(Modifier.width(100f).height(20f).color(Palette.buttonOutlineColor).onLeftClick(onClick)) { + box(Modifier.alignBoth(Alignment.Center).fillParent(padding = 1f).color(Palette.buttonBackgroundColor)) { + text(label) + } + } + } +} + +window.layout { + column(Arrangement.spacedBy(5f)) { + row(Arrangement.spacedBy(3f)) { + Button("Yes", ::accept)() + Button("No", ::reject)() + } + Button("Cancel", ::cancel)(Modifier.width(30f).height(10f)) + } +} +``` + +The main disadvantage here is that your custom component can no longer be a `box`/`row`/`column`, you need to deal with +positioning of your immediate children manually. And, if your component is sized bottom-up, you also need to deal with +the sizing of it manually. + +### Arrangement + +`Arrangement` provides a way to declare how multiple components should be arranged (i.e. where surplus space goes) on +a particular axis. + +In terms old regular Elementa, it provides position constraints (for one axis) for all children of a given container and +centrally decides where all components will go. + +Note: A single `Arrangement` cannot currently be shared between multiple rows/columns; this should be fixed at some + point, because `Alignment` and `Modifier` do allow for this (and even explicitly encourage it). + +Suppose we have a row with three equally sized children and 8 pixels of surplus space: +```kotlin +row(Modifier.width(38), arrangementGoesHere) { + box(Modifier.width(10)) + box(Modifier.width(10)) + box(Modifier.width(10)) +} +``` + +#### SpacedAround + +Simply divides the available free space in two and places it on both sides of the children: +``` +| |--------||--------||--------| | +``` + +#### SpacedBetween + +Divides up the available free space and places it between the children: +``` +||--------| |--------| |--------|| +``` + +#### SpacedEvenly + +Divides up the available free space and places it between and around the children: +``` +| |--------| |--------| |--------| | +``` + +#### spacedBy + +Uses a given fixed `spacing` between the children and positions the entire block according to the given `float`: +``` +Arrangement.spacedBy(1f, FloatPosition.Start) +||--------| |--------| |--------| | +Arrangement.spacedBy(1f, FloatPosition.Center) +| |--------| |--------| |--------| | +Arrangement.spacedBy(1f, FloatPosition.End) +| |--------| |--------| |--------|| +``` + +Unlike the previous arrangements, `spacedBy` is usually used for bottom-up layouts. If no explicit width is set on the +row, its width will be the sum of the widths of its children plus the spacing between them: + +```kotlin +row(Arrangement.spacedBy(1)) { + box(Modifier.width(10)) + box(Modifier.width(10)) + box(Modifier.width(10)) +} +// Results in ||--------| |--------| |--------|| +``` + +Note: Currently the default FloatPosition is `Start`, this may be changed to `Center` in the future. + Use of the floating parameter is actually quite rare because spacedBy in top-down layouts is quite rare and + because the same effect can be achieved by putting a box around a bottom-up spacedBy row and then simply + controlling the float of the entire row within that box. + +#### equalWeight + +Uses a given fixed `spacing` between the children and distributes remaining space **into** the children. +That is, it overwrites the width of all its children and sets them all to the same width such that no surplus space +remains. + +``` +Arrangement.equalWeight(1f) +||----------| |----------| |----------|| +``` + +Note how the children end up being 12 wide, not 10. +But it can also shrink the children: + +``` +Arrangement.equalWeight(10f) +||----| |----| |----|| +``` + +### Dynamic content + +So far we have only built static component trees but quite frequently components will only be visible under certain +circumstances (like when a certain State is true), usually this boils down calling `hide` and `show` on the component +from the state change listener. But doing this correctly is actually deceptively hard (especially keeping the correct +order between multiple conditional components). + +With Layout DSL, this is now possible and it's stupidly simple (at least to use; the implementation, not so much): +```kotlin +val myBoolState = mutableStateOf(true) +window.layout { + text("Before") + if_(myBoolState) { + text("It's true!") + } `else` { + box(Modifier.color(Color.RED)) { + text("Oh no") + } + } + text("After") +} +``` + +This will at first only evaluate one of the two inner blocks. +When the value changes, then it'll then remove all children from that block and evaluate the other block. +By default, if the value then changes again, it will have remembered the components of the original block and simple add +them back after removing the ones from the other block. + +This is usually what you want because it makes switching back and forth fast at the usually small cost of keeping +components for both in memory. +If for some reason you do not want to keep the inactive components around, you can pass `cache = false` in the `if_` +call to disable this caching. It will then re-evaluate the branches on each change. + +Note that without the cache, care must be taken to not create any memory leaks when using StateV1, as change listeners +registered on StateV1 do not get cleaned up automatically until both the state and all its listeners are eligible for +garbage collection. + +#### bind + +But what if you have more than just true and false? +`bind` will accept any state, and re-evaluate the block whenever its value changes. + +Note that unlike with `if`, since there can theoretically be an unbounded amount of values, caching is disabled for +`bind` by default. You can enable it via the optional parameter and probably should do so wherever it makes sense. + +```kotlin +val myStrState = mutableStateOf("Test") +window.layout { + bind(myStrState) { myStr -> + text("My string is $myStr") + } +} +``` + +Because it is quite common, there is a specialized variant meant for states that can be null: +```kotlin +val myStrState = mutableStateOf(null) +window.layout { + ifNotNull(myStrState) { myStr -> + text("My string is $myStr (and never null)") + } + + // Effectively equivalent to: + bind(myStrState) { myStr -> + if (myStr == null) return@bind + text("My string is $myStr (and never null)") + } +} +``` + +#### forEach + +But what if you want a variable number of components? +`forEach` will accept a `ListState` and call the block for each `T`, disposing of the correct scopes when values are +removed from the state and inserting new scopes at the right place as new values are added to the scope. + +Note that unlike with `if`, since there can theoretically be an unbounded amount of values, caching is disabled for +`forEach` by default. +You can enable it via the optional parameter and probably should do so wherever it makes sense. This is especially true +if you have a practically limited amount of values but want to implement something like search where having to re-create +all the components whenever you remove characters from your search term would be quite expensive. + +```kotlin +val myListState = mutableListStateOf("a", "b", "c") +window.layout { + forEach(myListState) { myStr -> + text(myStr) + } +} +``` + +### Hovering + +Components will frequently change their looks when they are hovered. +This is generally achieved with the `whenHovered` modifier. +For many modifiers there also exist variants with the `hovered` prefix (e.g. `hoveredColor`) which are shortcuts for +this modifier. + +```kotlin +// A box that's red when hovered and black otherwise +box(Modifier.whenHovered(Modifier.color(Color.RED), Modifier.color(Color.BLACK)).then(size)) +// or, same thing, a black box that turns red when hovered: +box(Modifier.color(Color.Black).whenHovered(Modifier.color(Color.RED)).then(size)) +// or, same thing, with the `hovered`-prefixed `color` modifier +box(Modifier.color(Color.Black).hoverColor(Color.RED).then(size)) +``` + +A hover scope is **required** to use these (see next section). +This is because aside from toy examples, you usually want one. + +#### Hover Scope + +Usually however, we don't actually care about whether any specific component, like the text of a button, is hovered. +What we really care about is whether the button as a whole is hovered. +And, if it is, then all children of the button should act as if they are hovered as well. + +Such a scope of elements (specifically a sub-tree of components), that should all act together with respect to hovering, +is declared with the `hoverScope` modifier. + +If declared with default arguments on a component, the hover state of that container will be tracked, and all +(direct and indirect) children as well as the component itself will follow that state for their `whenHovered` modifiers. + +If more control is required over when the hover state is true or false, the `hoverScope` modifier can optionally +receive a `State` to use as the hover state. + +(TODO this currently uses StateV1, and as such may cause leaks if the children are highly dynamic; need to update to V2) + +Note: The `hoverScope` modifier should not be confused with the `UIComponent.hoverScope` extension function. + The former is used to declare a new hover scope while the latter is used to retrieve the hover scope applicable + to a component like `whenHovered` does. + It should also not be confused with the `UIComponent.hoverState` extension function, which is a lower-level + function commonly used prior to the introduction of hover scopes. It simply returns a State for whether that + specific component is hovered. That is what is used by the `hoverScope` modifier if you do not pass a custom + State. + +#### Default hover scope and inheritance + +There are standalone components which will usually want to be treated as a single hover scope, e.g. a button component +will in the vast majority of cases be the root of a hover scope. +To that end, they will usually apply the `hoverScope` modifier to themselves (or `makeHoverScope` for class components). + +But what if we want to disable hovering of such a component (assuming the component doesn't have a dedicated way to do +that)? + +This is not much of a problem, calling `hoverScope` again on the same component will simply replace the default one +installed by the component itself: `Button()(Modifier.hoverScope(BasicState(false)))` + +But what if you want to use such a component as part of a larger component where hovering anywhere on the larger +component will affect that component as well? + +By default hover scopes are not inherited, meaning even though both scopes will show as hovered when you place your +cursor in such a way that it is inside both, the same is not true when it is only over the larger one. In that case, by +default, only the larger component will appear hovered. +We can however override the hover scope of the inner component as above and simply pass the hover state of the outer +component for it to use. The `inheritHoverScope` modifier when applied to the inner component does exactly that. + +## Style Guide + +This section list various code style rules related to the Layout DSL and surrounding mechanisms. +Most of these are fuzzy and much less strict than general code style guidelines and should be considered recommendations +rather than hard rules. +Where possible, you should follow these as they aid in making the code easier to read for anyone used to seeing code +that follows these rules, but if they worsen readability in some specific case, then you should not feel obliged to +follow them just for the sake of it. + +This list is likely incomplete and should be expanded whenever we find us adhering to any yet unwritten rules. + +The guiding principle which most of these follow is to keep in mind the original purpose of the Layout DSL as explained +in the "Motivation" section: Being able to understand the overall structure/layout of a GUI without having to run or +laboriously mentally evaluate them. + +### Keep it short + +Within the DSL, keep the closing parenthesis on the same line as the respective opening parenthesis. +If that makes the line too long, you're probably doing too much in there. Some of your options are: + +If you have a click handler or any other non-trivial lambda in there, move it to a function outside the Layout DSL. + +If your modifier chain is too long, remember that modifiers were meant to be re-usable, so there's usually nothing +wrong with declaring a local variable with the modifier beforehand and then using that (potentially in multiple places). +Do try to keep non-custom layout information (i.e. positioning and sizing modifiers) in-line though, as these are +usually required to understand the layout, which is the point of the DSL after all. +Another exception to this is the `hoverScope` modifier due to it conceptually being more of a property of the entire +sub-tree rather than any specific component. + +If you are deeply indented (or even if you are not yet), consider extracting out function components where it makes +semantic sense. +This is especially useful for things with click handler or other lambdas (like mapped states) as these can nicely be +put at the start of the function component, where they're still close to their usage, just not too close. + +### Miscellaneous + +- Instead of `whenHovered`, prefer using the `hovered` variants and the regular variant where those exist, + e.g. `.color(regular).hoveredColor(hovered)`. Easier to read because the regular/non-hovered variant can go first. +- When you need a `row` or `column` with non-standard arrangement but no special modifier, use the overload instead + of using a keyword argument to pass the arrangement. The keyword is quite long and standard arrangements are prefixed + by `Arrangement.` already. +- Usually `align(Center)` is redundant. See the "Containers" section. +- Avoid `onMouseEnter`/`onMouseLeave`/`whenMouseEntered`. These do not even handle occlusion properly. + See the "Hovering" section instead. +- When order of modifiers does not matter semantically, prefer + - size before position before everything else, `hoverScope` last + - width before height, x before Y diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/alignment.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/alignment.kt new file mode 100644 index 0000000..17080be --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/alignment.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.PositionConstraint +import gg.essential.elementa.dsl.pixels +import gg.essential.gui.common.constraints.CenterPixelConstraint + +interface Alignment { + fun applyHorizontal(component: UIComponent): () -> Unit + fun applyVertical(component: UIComponent): () -> Unit + + companion object { + @Suppress("FunctionName") + fun Start(padding: Float): Alignment = BasicAlignment { padding.pixels() } + @Suppress("FunctionName") + fun Center(roundUp: Boolean): Alignment = BasicAlignment { CenterPixelConstraint(roundUp) } + @Suppress("FunctionName") + fun End(padding: Float): Alignment = BasicAlignment { padding.pixels(alignOpposite = true) } + + val Start: Alignment = Start(0f) + val Center: Alignment = BasicAlignment { CenterPixelConstraint() } + val End: Alignment = End(0f) + + val TrueCenter: Alignment = BasicAlignment { CenterConstraint() } + } +} + +private class BasicAlignment(private val constraintFactory: () -> PositionConstraint) : Alignment { + override fun applyHorizontal(component: UIComponent): () -> Unit { + return BasicXModifier(constraintFactory).applyToComponent(component) + } + + override fun applyVertical(component: UIComponent): () -> Unit { + return BasicYModifier(constraintFactory).applyToComponent(component) + } +} + +fun Modifier.alignBoth(alignment: Alignment) = alignHorizontal(alignment).alignVertical(alignment) + +fun Modifier.alignHorizontal(alignment: Alignment) = this then HorizontalAlignmentModifier(alignment) + +fun Modifier.alignVertical(alignment: Alignment) = this then VerticalAlignmentModifier(alignment) + +private class HorizontalAlignmentModifier(private val alignment: Alignment) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + return alignment.applyHorizontal(component) + } +} + +private class VerticalAlignmentModifier(private val alignment: Alignment) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + return alignment.applyVertical(component) + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/arrangement.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/arrangement.kt new file mode 100644 index 0000000..191a87e --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/arrangement.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.* +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import gg.essential.elementa.utils.ObservableAddEvent +import gg.essential.elementa.utils.ObservableClearEvent +import gg.essential.elementa.utils.ObservableListEvent +import gg.essential.elementa.utils.ObservableRemoveEvent +import gg.essential.elementa.utils.roundToRealPixels + +interface Arrangement { + fun initialize(component: UIComponent, axis: Axis) + + companion object { + val SpaceAround: Arrangement get() = SpaceAroundArrangement.Factory + val SpaceBetween: Arrangement get() = SpaceBetweenArrangement.Factory + val SpaceEvenly: Arrangement get() = SpaceEvenlyArrangement.Factory + + fun spacedBy(): Arrangement = SpacedArrangement.DefaultFactory + fun spacedBy(spacing: Float = 0f, float: FloatPosition = FloatPosition.CENTER): Arrangement = SpacedArrangement.Factory(spacing, float) + fun equalWeight(spacing: Float = 0f): Arrangement = EqualWeightArrangement.Factory(spacing) + } +} + +abstract class ArrangementInstance( + val mainAxis: Axis, +) { + internal var recalculatePositions = true + internal var recalculateSizes = true + + protected lateinit var boundComponent: UIComponent + private set + protected val lastPosValues = hashMapOf() + protected val lastSizeValues = hashMapOf() + + abstract fun layoutPositions() + open fun layoutSizes() {} + abstract fun getPadding(child: UIComponent): Float + + fun getPosValue(component: UIComponent): Float { + if (recalculatePositions) { + layoutPositions() + recalculatePositions = false + } + return lastPosValues[component] + ?: error("Component $component's position was not laid out by arrangement $this") + } + + fun getSizeValue(component: UIComponent): Float { + if (recalculateSizes) { + layoutSizes() + recalculateSizes = false + } + return lastSizeValues[component] + ?: error("Component $component's size was not laid out by arrangement $this") + } + + @Suppress("UNCHECKED_CAST") + open fun initialize(component: UIComponent) { + boundComponent = component + component.children.forEach(::conformChild) + component.children.addObserver { _, arg -> + when (val event = arg as? ObservableListEvent ?: return@addObserver) { + is ObservableAddEvent -> conformChild(event.element.value) + is ObservableRemoveEvent -> { + lastPosValues.remove(event.element.value) + lastSizeValues.remove(event.element.value) + } + is ObservableClearEvent -> { + lastPosValues.clear() + lastSizeValues.clear() + } + } + } + } + + open fun conformChild(child: UIComponent) { + when (mainAxis) { + Axis.HORIZONTAL -> child.setX(ArrangementControlledPositionConstraint(this)) + Axis.VERTICAL -> child.setY(ArrangementControlledPositionConstraint(this)) + } + } + + protected fun UIComponent.getMainAxisSize() = when (mainAxis) { + Axis.HORIZONTAL -> getWidth() + Axis.VERTICAL -> getHeight() + } + + protected fun UIComponent.getCrossAxisSize() = when (mainAxis) { + Axis.HORIZONTAL -> getHeight() + Axis.VERTICAL -> getWidth() + } + + protected fun UIComponent.getMainAxisStart() = when (mainAxis) { + Axis.HORIZONTAL -> getLeft() + Axis.VERTICAL -> getTop() + } + + protected fun UIComponent.getCrossAxisStart() = when (mainAxis) { + Axis.HORIZONTAL -> getTop() + Axis.VERTICAL -> getLeft() + } +} + +private open class SpacedArrangement( + axis: Axis, + protected val spacing: Float = 0f, + protected val floatPosition: FloatPosition = FloatPosition.CENTER, +) : ArrangementInstance(axis) { + open fun getSpacing(parent: UIComponent) = spacing + + open fun getStartOffset(parent: UIComponent, spacing: Float): Float { + val childrenSize = parent.children.sumOf { it.getMainAxisSize() } + spacing * (parent.children.size - 1) + return when (floatPosition) { + FloatPosition.START -> 0f + FloatPosition.CENTER -> parent.getMainAxisSize() / 2 - childrenSize / 2 + FloatPosition.END -> parent.getMainAxisSize() - childrenSize + } + } + + override fun layoutPositions() { + val spacing = getSpacing(boundComponent).roundToRealPixels() + var nextStart = boundComponent.getMainAxisStart() + getStartOffset(boundComponent, spacing).roundToRealPixels() + boundComponent.children.forEach { + lastPosValues[it] = nextStart + nextStart += it.getMainAxisSize() + spacing + } + } + + override fun getPadding(child: UIComponent): Float { + return if (child === boundComponent.children.last()) 0f else getSpacing(boundComponent).roundToRealPixels() + } + + data class Factory(val spacing: Float, val floatPosition: FloatPosition) : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpacedArrangement(axis, spacing, floatPosition).initialize(component) + } + } + + object DefaultFactory : Arrangement by Factory(0f, FloatPosition.CENTER) +} + +private class SpaceBetweenArrangement(axis: Axis) : SpacedArrangement(axis) { + override fun getSpacing(parent: UIComponent): Float { + return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size - 1) + } + + object Factory : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpaceBetweenArrangement(axis).initialize(component) + } + } +} + +private class SpaceEvenlyArrangement(axis: Axis) : SpacedArrangement(axis) { + override fun getSpacing(parent: UIComponent): Float { + return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size + 1) + } + + override fun getStartOffset(parent: UIComponent, spacing: Float): Float { + return spacing + } + + object Factory : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpaceEvenlyArrangement(axis).initialize(component) + } + } +} + +private class SpaceAroundArrangement(axis: Axis) : SpacedArrangement(axis) { + override fun getSpacing(parent: UIComponent): Float { + return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / parent.children.size + } + + override fun getStartOffset(parent: UIComponent, spacing: Float): Float { + return spacing / 2 + } + + object Factory : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpaceAroundArrangement(axis).initialize(component) + } + } +} + +private class EqualWeightArrangement(axis: Axis, spacing: Float) : SpacedArrangement(axis, spacing, FloatPosition.CENTER) { + override fun conformChild(child: UIComponent) { + super.conformChild(child) + when (mainAxis) { + Axis.HORIZONTAL -> child.setWidth(ArrangementControlledSizeConstraint(this)) + Axis.VERTICAL -> child.setHeight(ArrangementControlledSizeConstraint(this)) + } + } + + override fun layoutSizes() { + val childCount = boundComponent.children.size + val childSize = (boundComponent.getMainAxisSize() - (childCount - 1) * spacing) / childCount + boundComponent.children.forEach { + lastSizeValues[it] = childSize + } + } + + data class Factory(val spacing: Float) : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + EqualWeightArrangement(axis, spacing).initialize(component) + } + } +} + +private class ArrangementControlledPositionConstraint(private val arrangement: ArrangementInstance) : PositionConstraint, PaddingConstraint { + override var cachedValue = 0f + override var recalculate = true + set(value) { + field = value + if (value) { + arrangement.recalculatePositions = true + } + } + override var constrainTo: UIComponent? + get() = null + set(_) = error("Cannot bind an arrangement-controlled constraint to another component!") + + init { + arrangement.recalculatePositions = true + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + } + + override fun getXPositionImpl(component: UIComponent) = arrangement.getPosValue(component) + + override fun getYPositionImpl(component: UIComponent) = arrangement.getPosValue(component) + + override fun getHorizontalPadding(component: UIComponent): Float { + return if (arrangement.mainAxis == Axis.HORIZONTAL) arrangement.getPadding(component) else 0f + } + + override fun getVerticalPadding(component: UIComponent): Float { + return if (arrangement.mainAxis == Axis.VERTICAL) arrangement.getPadding(component) else 0f + } +} + +private class ArrangementControlledSizeConstraint(private val arrangement: ArrangementInstance) : SizeConstraint { + override var cachedValue = 0f + override var recalculate = true + set(value) { + field = value + if (value) { + arrangement.recalculateSizes = true + } + } + override var constrainTo: UIComponent? + get() = null + set(_) = error("Cannot bind an arrangement-controlled constraint to another component!") + + init { + arrangement.recalculateSizes = true + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + } + + override fun getWidthImpl(component: UIComponent) = arrangement.getSizeValue(component) + + override fun getHeightImpl(component: UIComponent) = arrangement.getSizeValue(component) + + override fun getRadiusImpl(component: UIComponent) = arrangement.getSizeValue(component) +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/axis.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/axis.kt new file mode 100644 index 0000000..109cd62 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/axis.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +enum class Axis { + HORIZONTAL, + VERTICAL +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/basicModifiers.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/basicModifiers.kt new file mode 100644 index 0000000..35ecf2d --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/basicModifiers.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.* + +infix fun Modifier.then(other: UIComponent.() -> () -> Unit) = this then BasicModifier(other) + +private class BasicModifier(private val setup: UIComponent.() -> () -> Unit) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + return component.setup() + } +} + +class BasicXModifier(private val constraint: () -> XConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldX = component.constraints.x + component.setX(constraint()) + return { + component.setX(oldX) + } + } +} + +class BasicYModifier(private val constraint: () -> YConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldY = component.constraints.y + component.setY(constraint()) + return { + component.setY(oldY) + } + } +} + +class BasicWidthModifier(private val constraint: () -> WidthConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldWidth = component.constraints.width + component.setWidth(constraint()) + return { + component.setWidth(oldWidth) + } + } +} + +class BasicHeightModifier(private val constraint: () -> HeightConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldHeight = component.constraints.height + component.setHeight(constraint()) + return { + component.setHeight(oldHeight) + } + } +} + +class BasicColorModifier(private val constraint: () -> ColorConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldColor = component.constraints.color + component.setColor(constraint()) + return { + component.setColor(oldColor) + } + } +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/color.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/color.kt new file mode 100644 index 0000000..2bce5fb --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/color.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ColorConstraint +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.dsl.animate +import gg.essential.elementa.dsl.toConstraint +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.gui.common.onSetValueAndNow +import gg.essential.gui.elementa.state.v2.color.toConstraint +import gg.essential.gui.elementa.state.v2.toV2 +import gg.essential.gui.util.hasWindow +import java.awt.Color +import gg.essential.gui.elementa.state.v2.State as StateV2 + +fun Modifier.color(color: Color) = this then BasicColorModifier { color.toConstraint() } + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.color(color: State) = this then BasicColorModifier { color.toConstraint() } + +fun Modifier.color(color: StateV2) = this then BasicColorModifier { color.toConstraint() } + +fun Modifier.hoverColor(color: Color, duration: Float = 0f) = hoverColor(BasicState(color), duration) + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.hoverColor(color: State, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration)) + +fun Modifier.hoverColor(color: StateV2, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration)) + +fun Modifier.animateColor(color: Color, duration: Float = .3f) = animateColor(BasicState(color), duration) + +fun Modifier.animateColor(color: State, duration: Float = .3f) = animateColor(color.toV2(), duration) + +fun Modifier.animateColor(color: StateV2, duration: Float = .3f) = this then AnimateColorModifier(color, duration) + +private class AnimateColorModifier(private val colorState: StateV2, private val duration: Float) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldColor = component.constraints.color + + fun animate(color: ColorConstraint) { + if (component.hasWindow) { + component.animate { + setColorAnimation(Animations.OUT_EXP, duration, color) + } + } else { + component.setColor(color) + } + } + + val removeListenerCallback = colorState.onSetValueAndNow(component) { + animate(it.toConstraint()) + } + + return { + removeListenerCallback() + animate(oldColor) + } + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/containers.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/containers.kt new file mode 100644 index 0000000..f2906fc --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/containers.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +@file:OptIn(ExperimentalContracts::class) + +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.ScrollComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint +import gg.essential.elementa.constraints.ChildBasedSizeConstraint +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.dsl.boundTo +import gg.essential.elementa.dsl.childOf +import gg.essential.elementa.dsl.coerceAtLeast +import gg.essential.elementa.dsl.percent +import gg.essential.elementa.dsl.pixels +import gg.essential.gui.common.HollowUIContainer +import gg.essential.gui.common.constraints.AlternateConstraint +import gg.essential.gui.common.constraints.SpacedCramSiblingConstraint +import gg.essential.gui.elementa.state.v2.* +import gg.essential.universal.UMatrixStack +import java.awt.Color +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +fun LayoutScope.box(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit = {}): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val container = TransparentBlock().apply { + componentName = "BoxContainer" + setWidth(ChildBasedSizeConstraint()) + setHeight(ChildBasedSizeConstraint()) + } + container.addChildModifier(Modifier.alignHorizontal(Alignment.Center).alignVertical(Alignment.Center)) + return container(modifier = modifier, block = block) +} + +fun LayoutScope.row(horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return row(Modifier, horizontalArrangement, verticalAlignment, block) +} +fun LayoutScope.row(modifier: Modifier, horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val rowContainer = TransparentBlock().apply { + componentName = "RowContainer" + setWidth(ChildBasedSizeConstraint()) + setHeight(ChildBasedMaxSizeConstraint()) + } + + rowContainer.addChildModifier(Modifier.alignVertical(verticalAlignment)) + + rowContainer(modifier = modifier, block = block) + horizontalArrangement.initialize(rowContainer, Axis.HORIZONTAL) + + return rowContainer +} + +fun LayoutScope.column(verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + return column(Modifier, verticalArrangement, horizontalAlignment, block) +} +fun LayoutScope.column(modifier: Modifier, verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val columnContainer = TransparentBlock().apply { + componentName = "ColumnContainer" + setWidth(ChildBasedMaxSizeConstraint()) + setHeight(ChildBasedSizeConstraint()) + } + + columnContainer.addChildModifier(Modifier.alignHorizontal(horizontalAlignment)) + + columnContainer(modifier = modifier, block = block) + verticalArrangement.initialize(columnContainer, Axis.VERTICAL) + + return columnContainer +} + +fun LayoutScope.flowContainer( + modifier: Modifier = Modifier, + // TODO ideally we can make this use Arrangement on a per-row basis, currently it's just always SpaceBetween + minSeparation: () -> WidthConstraint = { 0.pixels }, + verticalSeparation: () -> WidthConstraint = { 0.pixels }, + block: LayoutScope.() -> Unit = {}, +): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val flowContainer = TransparentBlock().apply { + componentName = "FlowContainer" + setHeight(ChildBasedSizeConstraint()) + } + + val childModifier = Modifier + .then(BasicXModifier { SpacedCramSiblingConstraint(minSeparation(), 0.pixels) }) + .then(BasicYModifier { SpacedCramSiblingConstraint(minSeparation(), 0.pixels, verticalSeparation()) }) + flowContainer.addChildModifier(childModifier) + + flowContainer(modifier = modifier, block = block) + + return flowContainer +} + +fun LayoutScope.scrollable( + modifier: Modifier = Modifier, + horizontal: Boolean = false, + vertical: Boolean = false, + pixelsPerScroll: Float = 15f, + block: LayoutScope.() -> Unit = {}, +): ScrollComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + if (!horizontal && !vertical) { + throw IllegalArgumentException("Either `horizontal` or `vertical` or both must be `true`.") + } + + val outer = ScrollComponent( + horizontalScrollEnabled = horizontal, + verticalScrollEnabled = vertical, + pixelsPerScroll = pixelsPerScroll, + ) + val inner = outer.children.first() + // Need an extra wrapper because ScrollComponent does stupid things which breaks padding in the inner component + val content = HollowUIContainer() childOf outer // actually adds to `inner` because ScrollComponent redirects it + + outer.apply { + componentName = "scrollable" + setWidth(ChildBasedSizeConstraint() boundTo content) + setHeight(ChildBasedSizeConstraint() boundTo content) + } + inner.apply { + componentName = "scrollableInternal" + setWidth(100.percent boundTo content) + setHeight(100.percent boundTo content) + } + content.apply { + componentName = "scrollableContent" + setWidth(AlternateConstraint(ChildBasedSizeConstraint(), 100.percent boundTo outer).coerceAtLeast(AlternateConstraint(100.percent boundTo outer, 0.pixels))) + setHeight(AlternateConstraint(ChildBasedSizeConstraint(), 100.percent boundTo outer).coerceAtLeast(AlternateConstraint(100.percent boundTo outer, 0.pixels))) + addChildModifier(Modifier.alignBoth(Alignment.Center)) + } + + outer(modifier = modifier) + + block(LayoutScope(content, this, content)) + + return outer +} + +fun LayoutScope.floatingBox( + modifier: Modifier = Modifier, + floating: State = stateOf(true), + block: LayoutScope.() -> Unit = {}, +): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + fun UIComponent.isMounted(): Boolean = + parent == this || (this in parent.children && parent.isMounted()) + + // Elementa's floating system is quite tricky to work with because components that are floating are added into a + // persistent list but will not automatically be removed from that list when they're removed from the component + // tree, and as such will continue to render. + // This class tries to work around that by canceling `draw` and automatically un-floating itself in such cases, + // as well as automatically adding itself back to the floating list when it is put back into the component tree. + class FloatableContainer : UIBlock(Color(0, 0, 0, 0)) { + val shouldBeFloating: Boolean + get() = floating.get() + + // Keeps track of the current floating state because the parent field of the same name is private + @set:JvmName("setFloating_") + var isFloating: Boolean = false + set(value) { + if (field == value) return + field = value + setFloating(value) + } + + override fun animationFrame() { + // animationFrame is called from the regular tree traversal, so it's safe to directly update the floating + // list from here + isFloating = shouldBeFloating + + super.animationFrame() + } + + override fun draw(matrixStack: UMatrixStack) { + // If we're no longer mounted in the component tree, we should no longer draw + if (!isMounted()) { + // and if we're still floating (likely the case because that'll be why we're still drawing), then + // we also need to un-float ourselves + if (isFloating) { + // since this is likely called from the code that iterates over the floating list to draw each + // component, modifying the floating list here would result in a CME, so we need to delay this. + Window.enqueueRenderOperation { + // Note: we must not assume that our shouldBe state hasn't changed since we scheduled this + isFloating = shouldBeFloating && isMounted() + } + } + return + } + + // If we should be floating but aren't right now, then this isn't being called from the floating draw loop + // and it should be safe for us to immediately set us as floating. + // Doing so will add us to the floating draw loop and thereby allow us to draw later. + if (shouldBeFloating && !isFloating) { + isFloating = true + return + } + + // If we should not be floating but are right now, then this is similar to the no-longer-mounted case above + // i.e. we want to un-float ourselves. + // Except we're still mounted so we do still want to draw the content (this means it'll be floating for one + // more frame than it's supposed to but there isn't anything we can really do about that because the regular + // draw loop has already concluded by this point). + if (!shouldBeFloating && isFloating) { + Window.enqueueRenderOperation { isFloating = shouldBeFloating } + super.draw(matrixStack) + return + } + + // All as it should be, can just draw it + super.draw(matrixStack) + } + } + + val container = FloatableContainer().apply { + componentName = "floatingBox" + setWidth(ChildBasedSizeConstraint()) + setHeight(ChildBasedSizeConstraint()) + } + container.addChildModifier(Modifier.alignBoth(Alignment.Center)) + return container(modifier = modifier, block = block) +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/events.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/events.kt new file mode 100644 index 0000000..4d7dc60 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/events.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.events.UIClickEvent +import gg.essential.gui.elementa.state.v2.toV2 +import gg.essential.gui.util.Tag +import gg.essential.gui.util.hoverScope +import gg.essential.gui.util.makeHoverScope +import gg.essential.gui.util.addTag +import gg.essential.gui.util.removeTag + +import gg.essential.elementa.state.State as StateV1 +import gg.essential.gui.elementa.state.v2.State as StateV2 + +inline fun Modifier.onLeftClick(crossinline callback: UIComponent.() -> Unit) = this then { + val listener: UIComponent.(event: UIClickEvent) -> Unit = { + if (it.mouseButton == 0) { + callback() + } + } + onMouseClick(listener) + return@then { mouseClickListeners.remove(listener) } +} + +/** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */ +fun Modifier.hoverScope(state: StateV1? = null) = + then { makeHoverScope(state); { throw NotImplementedError() } } + +/** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */ +fun Modifier.hoverScope(state: gg.essential.gui.elementa.state.v2.State) = + then { makeHoverScope(state); { throw NotImplementedError() } } + +/** + * Replaces the existing hover scope declared on this component with one which simply inherits from the parent scope. + * Can effectively be used to remove a scope from an otherwise self-contained component to join it with other custom + * components surrounding it. + */ +fun Modifier.inheritHoverScope() = + then { makeHoverScope(hoverScope(parentOnly = true)); { throw NotImplementedError() } } + +/** + * Applies [hoverModifier] while the component is hovered, otherwise applies [noHoverModifier] (or nothing by default). + * + * Whether a component is considered "hovered" depends solely on whether its [hoverScope] says that it is. + * It is not necessarily related to whether the mouse cursor is on top of the component (e.g. the label of a button may + * be considered hovered when the overall button is hovered, even when the cursor isn't on the text itself). + * + * A [Modifier.hoverScope] is **require** on the component or one of its parents. + */ +fun Modifier.whenHovered(hoverModifier: Modifier, noHoverModifier: Modifier = Modifier): Modifier = + then { Modifier.whenTrue(hoverScope(), hoverModifier, noHoverModifier).applyToComponent(this) } + +/** + * Provides the [hoverScope] to be evaluated in a lambda which returns a modifier + */ +fun Modifier.withHoverState(func: (StateV2) -> Modifier) = + then { func(hoverScope().toV2()).applyToComponent(this) } + +/** Applies a Tag to this component. See [UIComponent.addTag]. */ +fun Modifier.tag(tag: Tag) = then { addTag(tag); { removeTag(tag) } } diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/float.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/float.kt new file mode 100644 index 0000000..4c02e45 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/float.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +enum class FloatPosition { + START, + CENTER, + END +} \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/layout.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/layout.kt new file mode 100644 index 0000000..3b2c97f --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/layout.kt @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +@file:OptIn(ExperimentalContracts::class) +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.State +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.gui.common.ListState +import gg.essential.gui.common.not +import gg.essential.gui.elementa.state.v2.* +import gg.essential.gui.util.hoveredState +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import gg.essential.gui.elementa.state.v2.ListState as ListStateV2 +import gg.essential.gui.elementa.state.v2.State as StateV2 + +class LayoutScope( + private val component: UIComponent, + private val parentScope: LayoutScope?, + val stateScope: ReferenceHolder, +) { + /** + * As the name says, don't use this unless you really have to. + */ + val containerDontUseThisUnlessYouReallyHaveTo: UIComponent + get() = component + + private val childrenScopes = mutableListOf() + + operator fun T.invoke(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit = {}): T { + this@LayoutScope.component.getChildModifier().applyToComponent(this) + modifier.applyToComponent(this) + + val childScope = LayoutScope(this, this@LayoutScope, this) + childrenScopes.add(childScope) + + childScope.block() + + val index = childScope.findNextIndexIn(component) ?: 0 + component.insertChildAt(this, index) + + return this + } + + operator fun LayoutDslComponent.invoke(modifier: Modifier = Modifier) = layout(modifier) + + @Deprecated("Use Modifier.hoverScope() and Modifier.whenHovered(), instead.") + fun hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true) = component.hoveredState(hitTest, layoutSafe) + + @Suppress("FunctionName") + fun if_(state: State, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl { + forEach(ListState.from(state.map { if (it) listOf(Unit) else emptyList() }), cache) { block() } + return IfDsl({ !state }, cache) + } + + fun if_(state: StateV2, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl { + return if_(state.toV1(component), cache, block) + } + + fun ifNotNull(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { + forEach(ListState.from(state.map { listOfNotNull(it) }), cache) { block(it) } + return IfDsl({ state.map { it == null } }, true) + } + + fun ifNotNull(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { + return ifNotNull(state.toV1(component), cache, block) + } + + fun if_(condition: StateByScope.() -> Boolean, cache: Boolean = false, block: LayoutScope.() -> Unit): IfDsl { + return if_(stateBy(condition), cache, block) + } + + fun ifNotNull(stateBlock: StateByScope.() -> T?, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { + return ifNotNull(stateBy(stateBlock), cache, block) + } + + class IfDsl(internal val elseState: () -> State, internal var cache: Boolean) + + infix fun IfDsl.`else`(block: LayoutScope.() -> Unit) { + if_(elseState(), cache, block) + } + + /** Makes available to the inner scope the value of the given [state]. */ + fun bind(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + forEach(ListState.from(state.map { listOf(it) }), cache) { block(it) } + } + + /** Makes available to the inner scope the value of the given [state]. */ + fun bind(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + bind(state.toV1(component), cache, block) + } + + /** Makes available to the inner scope the value derived from the given [stateBlock]. */ + fun bind(stateBlock: StateByScope.() -> T, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + bind(stateBy(stateBlock), cache, block) + } + + /** + * Repeats the inner block for each element in the given list state. + * If the list state changes, components from old scopes are removed and new scopes are created and initialized as + * required. + * Order relative to other components within the same [layout] call is kept automatically at all times. + * + * Note that given old scopes are discarded, care must be taken to not inadvertently leak child components, e.g. via + * listener subscriptions or other links that cannot be cleaned up automatically. + * If the space of possible [T] is very limited, [cache] may be set to `true` to retain old scopes after they are + * removed and to re-use them if their corresponding [T] value is re-introduced at a later time. + * This requires that [T] be usable as a key in a HashMap. + */ + fun forEach(state: ListState, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + val forEachScope = LayoutScope(component, this@LayoutScope, stateScope) + childrenScopes.add(forEachScope) + + val cacheMap = + if (cache) mutableMapOf>() + else null + fun getCacheEntry(key: T) = cacheMap?.getOrPut(key) { mutableListOf() } + + fun add(index: Int, element: T) { + val cachedScope = getCacheEntry(element)?.removeLastOrNull() + if (cachedScope != null) { + forEachScope.childrenScopes.add(index, cachedScope) + if (forEachScope.isVirtualScopeMounted()) { + cachedScope.remount() + } + } else { + // If the `forEach` is not cached, we give each child scope its own reference holder. + // This scope will be dropped once the child scope is removed. + val childStateScope = if (cache) forEachScope.stateScope else ReferenceHolderImpl() + val newScope = LayoutScope(component, forEachScope, childStateScope) + + forEachScope.childrenScopes.add(index, newScope) + newScope.block(element) + if (!forEachScope.isVirtualScopeMounted()) { + newScope.unmount() + } + } + } + + fun remove(index: Int, element: T) { + val removedScope = forEachScope.childrenScopes.removeAt(index) + removedScope.unmount() + getCacheEntry(element)?.add(removedScope) + } + + fun clear(elements: List) { + forEachScope.childrenScopes.forEachIndexed { index, layoutScope -> + layoutScope.unmount() + getCacheEntry(elements[index])?.add(layoutScope) + } + forEachScope.childrenScopes.clear() + } + + state.get().forEachIndexed(::add) + state.onAdd(::add) + state.onRemove(::remove) + state.onSet { index, element, oldElement -> + remove(index, oldElement) + add(index, element) + } + state.onClear(::clear) + } + + /** + * StateV2 support for forEach + */ + fun forEach(list: ListStateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) = + forEach(ListState.from(list.toV1(component)), cache, block) + + /** Whether this scope is a virtual "forEach" scope. These share their target component with their parent scope. */ + private fun isVirtual(): Boolean { + return parentScope?.component == component + } + + /** Whether this virtual ("forEach") scope is presently (virtually) mounted inside its parent [component]. */ + private fun isVirtualScopeMounted(): Boolean { + val parent = parentScope ?: return true // if we don't have a parent, we can only assume that we're mounted + + // Check if this scope is currently mounted in its parent scope + if (this !in parent.childrenScopes) { + return false + } + + // If the parent scope is a virtual scope as well, we can only be mounted if it is + if (parent.isVirtual()) { + return parent.isVirtualScopeMounted() + } + + return true + } + + /** Removes from [component] all components that where added within this scope. */ + private fun unmount() { + for (childScope in childrenScopes) { + if (childScope.component == this.component) { + // This is a forEach scope, recurse down into its children + childScope.unmount() + } else { + component.removeChild(childScope.component) + } + } + } + + /** Inverse of [unmount]. Re-adds to [component] all components that where added within this scope. */ + private fun remount() { + for (childScope in childrenScopes) { + if (childScope.component == this.component) { + // This is a forEach scope, recurse down into its children + childScope.remount() + } else { + val index = childScope.findNextIndexIn(component) ?: 0 + component.insertChildAt(childScope.component, index) + } + } + } + + /** + * Finds the index in [parent]'s children at which a component should be inserted to end up right after [component]. + * Works even when [component] is not currently present in [parent] by recursively searching the layout tree. + * If [parent] has no children in the layout tree, `null` is returned. + */ + private fun findNextIndexIn(parent: UIComponent): Int? { + /** Searches this subtree for an index. */ + fun LayoutScope.searchSubTree(range: IntProgression = childrenScopes.indices.reversed()): Int? { + if (component == parent) { + // This is a node in the subtree belonging to [parent] (e.g. the main scope, or a forEach scope), + // so we recursively search the children + for (index in range) { + childrenScopes[index].searchSubTree() + ?.let { return it } + } + return null + } else { + // Check if this child is currently present within its parent + return parent.children.indexOf(component).takeIf { it != -1 } + } + } + + /** Searches by recursively traversing upwards the tree if no index can be found in this subtree. */ + fun LayoutScope.search(beforeScope: LayoutScope): Int? { + val beforeIndex = childrenScopes.indexOf(beforeScope) + + // Check all preceding siblings + searchSubTree((0 until beforeIndex).reversed()) + ?.let { return it } + + // If we can't find anything there, check the siblings one level up, recursively + val parentScope = parentScope ?: return null + // Though once we've found a scope that targets [parent], then we can stop ascending if we find a scope + // that doesn't target [parent] (i.e. one for parent's parent) because we only want to search all scopes + // targeting [parent]. + if (component == parent && parentScope.component != parent) { + return null + } + return parentScope.search(this) + } + + return parentScope?.search(this)?.let { it + 1 } + } +} + +/** + * Runs [block] to lay out children of `this` component. + * + * The passed [modifier], if any, is applied to `this` component. + * + * Note: This does **not** change the constraints of `this`. These must be set up manually or via the passed [modifier]. + * + * Note: Direct children of `this` will by default be top-left aligned as with all plain Elementa components. + * Consider using one of [layoutAsBox], [layoutAsRow], or [layoutAsColumn] instead to get the default center alignment + * that is typical for Layout DSL. + */ +inline fun UIComponent.layout(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit) { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + modifier.applyToComponent(this) + LayoutScope(this, null, this).block() +} + +/** + * Runs [block] to lay out children of `this` component as if it was a [box]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + */ +fun UIComponent.layoutAsBox(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit) { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + addChildModifier(Modifier.alignBoth(Alignment.Center)) + layout(modifier, block) +} + +/** + * Runs [block] to lay out children of `this` component as if it was a [row]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedMaxHeight]. + */ +fun UIComponent.layoutAsRow(modifier: Modifier, horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + addChildModifier(Modifier.alignVertical(verticalAlignment)) + layout(modifier, block) + horizontalArrangement.initialize(this, Axis.HORIZONTAL) + return this +} + +/** + * Runs [block] to lay out children of `this` component as if it was a [column]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedMaxWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedHeight]. + */ +fun UIComponent.layoutAsColumn(modifier: Modifier, verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + addChildModifier(Modifier.alignHorizontal(horizontalAlignment)) + layout(modifier, block) + verticalArrangement.initialize(this, Axis.VERTICAL) + return this +} + +// Overloads without Modifier argument +/** + * Runs [block] to lay out children of `this` component as if it was a [row]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedMaxHeight]. + */ +fun UIComponent.layoutAsRow(horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return layoutAsRow(Modifier, horizontalArrangement, verticalAlignment, block) +} +/** + * Runs [block] to lay out children of `this` component as if it was a [column]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedMaxWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedHeight]. + */ +fun UIComponent.layoutAsColumn(verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return layoutAsColumn(Modifier, verticalArrangement, horizontalAlignment, block) +} + + +interface LayoutDslComponent { + fun LayoutScope.layout(modifier: Modifier = Modifier) +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/lazy.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/lazy.kt new file mode 100644 index 0000000..1ad2ee6 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/lazy.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.universal.UMatrixStack + +/** + * Lazily initializes the inner scope by first only placing a [box] as described by [modifier] without any children and + * only initializing the inner scope once that box has been rendered once. + * + * This should be a last reserve for initializing a large list of poorly optimized components, not a common shortcut to + * "make it not lag". Properly profiling and fixing initialization performance issues should always be preferred. + */ +fun LayoutScope.lazyBox(modifier: Modifier = Modifier.fillParent(), block: LayoutScope.() -> Unit) { + val initialized = BasicState(false) + box(modifier) { + if_(initialized, cache = false /** don't need it; once initialized, we are never going back */) { + block() + } `else` { + LazyComponent(initialized)(Modifier.fillParent()) + } + } +} + +private class LazyComponent(private val initialized: State) : UIContainer() { + override fun draw(matrixStack: UMatrixStack) { + super.draw(matrixStack) + + Window.enqueueRenderOperation { + initialized.set(true) + } + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/modifier.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/modifier.kt new file mode 100644 index 0000000..2b0d20e --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/modifier.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent + +interface Modifier { + /** + * Applies this modifier to the given component, and returns a function which can be called to undo the applied changes. + */ + fun applyToComponent(component: UIComponent): () -> Unit + + infix fun then(other: Modifier) = if (other === Modifier) this else CombinedModifier(this, other) + + companion object : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit = {} + + override infix fun then(other: Modifier) = other + } +} + +private class CombinedModifier( + private val first: Modifier, + private val second: Modifier +) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val undoFirst = first.applyToComponent(component) + val undoSecond = second.applyToComponent(component) + return { + undoSecond() + undoFirst() + } + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/size.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/size.kt new file mode 100644 index 0000000..283a193 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/size.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.* +import gg.essential.elementa.constraints.animation.* +import gg.essential.elementa.dsl.* +import gg.essential.gui.common.constraints.FillConstraintIncludingPadding +import gg.essential.gui.common.onSetValueAndNow +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.stateOf +import gg.essential.gui.util.hasWindow + +fun Modifier.fillParent(fraction: Float = 1f, padding: Float = 0f) = + fillWidth(fraction, padding).fillHeight(fraction, padding) + +/** Fills [fraction] of parent width minus [leftPadding] and aligns [leftPadding] pixels from the left */ +fun Modifier.fillWidth(fraction: Float = 1f, leftPadding: Float, _desc: Int = 0) = + fillWidth(fraction, leftPadding, false).alignHorizontal(Alignment.Start(leftPadding)) + +/** Fills [fraction] of parent width minus [rightPadding] and aligns [rightPadding] pixels from the right */ +fun Modifier.fillWidth(fraction: Float = 1f, rightPadding: Float, _desc: Short = 0) = + fillWidth(fraction, rightPadding, false).alignHorizontal(Alignment.End(rightPadding)) + +/** Fills [fraction] of parent width minus [padding] from both sides */ +fun Modifier.fillWidth(fraction: Float = 1f, padding: Float = 0f) = fillWidth(fraction, padding, true) + +private fun Modifier.fillWidth(fraction: Float, padding: Float, doublePadding: Boolean) = + this then BasicWidthModifier { RelativeConstraint(fraction) - padding.pixels() * if (doublePadding) 2 else 1 } + +/** Fills [fraction] of parent height minus [topPadding] and aligns [topPadding] pixels from the top */ +fun Modifier.fillHeight(fraction: Float = 1f, topPadding: Float, _desc: Int = 0) = + fillHeight(fraction, topPadding, false).alignVertical(Alignment.Start(topPadding)) + +/** Fills [fraction] of parent height minus [bottomPadding] and aligns [bottomPadding] pixels from the bottom */ +fun Modifier.fillHeight(fraction: Float = 1f, bottomPadding: Float, _desc: Short = 0) = + fillHeight(fraction, bottomPadding, false).alignVertical(Alignment.End(bottomPadding)) + +/** Fills [fraction] of parent height minus [padding] from both sides */ +fun Modifier.fillHeight(fraction: Float = 1f, padding: Float = 0f) = fillHeight(fraction, padding, true) + +private fun Modifier.fillHeight(fraction: Float, padding: Float, doublePadding: Boolean) = + this then BasicHeightModifier { RelativeConstraint(fraction) - padding.pixels() * if (doublePadding) 2 else 1 } + +fun Modifier.childBasedSize(padding: Float = 0f) = childBasedWidth(padding).childBasedHeight(padding) + +fun Modifier.childBasedWidth(padding: Float = 0f) = this then BasicWidthModifier { ChildBasedSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.childBasedHeight(padding: Float = 0f) = this then BasicHeightModifier { ChildBasedSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.childBasedMaxSize(padding: Float = 0f) = childBasedMaxWidth(padding).childBasedMaxHeight(padding) + +fun Modifier.childBasedMaxWidth(padding: Float = 0f) = this then BasicWidthModifier { ChildBasedMaxSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.childBasedMaxHeight(padding: Float = 0f) = this then BasicHeightModifier { ChildBasedMaxSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.fillRemainingWidth() = this then BasicWidthModifier { FillConstraintIncludingPadding(useSiblings = true) } + +fun Modifier.fillRemainingHeight() = this then BasicHeightModifier { FillConstraintIncludingPadding(useSiblings = true) } + +fun Modifier.width(width: Float) = this then BasicWidthModifier { width.pixels() } + +fun Modifier.height(height: Float) = this then BasicHeightModifier { height.pixels() } + +fun Modifier.width(other: UIComponent) = this then BasicWidthModifier { (CopyConstraintFloat() boundTo other) } + +fun Modifier.height(other: UIComponent) = this then BasicHeightModifier { CopyConstraintFloat() boundTo other } + +fun Modifier.widthAspect(aspect: Float) = this then BasicWidthModifier { AspectConstraint(aspect) } + +fun Modifier.heightAspect(aspect: Float) = this then BasicHeightModifier { AspectConstraint(aspect) } + +fun Modifier.animateWidth(width: Float, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = animateWidth(stateOf { width.pixels }, duration, strategy) + +fun Modifier.animateHeight(height: Float, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = animateHeight(stateOf { height.pixels }, duration, strategy) + +fun Modifier.animateWidth(width: State<() -> WidthConstraint>, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = this then AnimateWidthModifier(width, duration, strategy) + +fun Modifier.animateHeight(height: State<() -> HeightConstraint>, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = this then AnimateHeightModifier(height, duration, strategy) + +fun Modifier.maxWidth(width: Float) = this then MaxWidthModifier(width) + +fun Modifier.maxHeight(height: Float) = this then MaxHeightModifier(height) + +private class AnimateWidthModifier(private val newWidth: State<() -> WidthConstraint>, private val duration: Float, private val strategy: AnimationStrategy) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldWidth = component.constraints.width + + fun animate(widthConstraint: WidthConstraint) { + if (component.hasWindow) { + component.animate { + setWidthAnimation(strategy, duration, widthConstraint) + } + } else { + component.setWidth(widthConstraint) + } + } + + val removeListenerCallback = newWidth.onSetValueAndNow(component) { animate(it()) } + + return { + removeListenerCallback() + animate(oldWidth) + } + } +} + +private class AnimateHeightModifier(private val newHeight: State<() -> HeightConstraint>, private val duration: Float, private val strategy: AnimationStrategy) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldHeight = component.constraints.height + + fun animate(heightConstraint: HeightConstraint) { + if (component.hasWindow) { + component.animate { + setHeightAnimation(strategy, duration, heightConstraint) + } + } else { + component.setHeight(heightConstraint) + } + } + + val removeListenerCallback = newHeight.onSetValueAndNow(component) { animate(it()) } + + return { + removeListenerCallback() + animate(oldHeight) + } + } +} + +private class MaxWidthModifier(private val width: Float) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldWidth = component.constraints.width + component.setWidth(min(oldWidth, width.pixels)) + + return { + component.setWidth(oldWidth) + } + } +} + +private class MaxHeightModifier(private val height: Float) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldHeight = component.constraints.height + component.setHeight(min(oldHeight, height.pixels)) + return { + component.setHeight(oldHeight) + } + } +} diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/state.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/state.kt new file mode 100644 index 0000000..38ca24c --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/state.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.state.State +import gg.essential.gui.common.onSetValueAndNow +import gg.essential.gui.elementa.state.v2.combinators.map +import gg.essential.gui.elementa.state.v2.State as StateV2 + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.then(state: State): Modifier { + return this then { + var reverse: (() -> Unit)? = null + + val cleanupState = state.onSetValueAndNow { + reverse?.invoke() + reverse = it.applyToComponent(this) + }; + + { + cleanupState() + reverse?.invoke() + reverse = null + } + } +} + +fun Modifier.then(state: StateV2): Modifier { + return this then { + var reverse: (() -> Unit)? = state.get().applyToComponent(this) + + val cleanupState = state.onSetValue(this) { + reverse?.invoke() + reverse = it.applyToComponent(this) + }; + + { + cleanupState() + reverse?.invoke() + reverse = null + } + } +} + +@Suppress("DeprecatedCallableAddReplaceWith") +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.whenTrue(state: State, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier = + then(state.map { if (it) activeModifier else inactiveModifier }) + +fun Modifier.whenTrue(state: StateV2, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier = + then(state.map { if (it) activeModifier else inactiveModifier }) \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/util.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/util.kt new file mode 100644 index 0000000..d51fcd0 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/layoutdsl/util.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.dsl.boundTo +import gg.essential.elementa.dsl.percent +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.effects.Effect +import gg.essential.gui.common.Spacer +import java.awt.Color + +@Suppress("FunctionName") +fun TransparentBlock() = UIBlock(Color(0, 0, 0, 0)) + +fun LayoutScope.spacer(width: Float, height: Float) = Spacer(width = width.pixels, height = height.pixels)() +fun LayoutScope.spacer(width: Float, _desc: WidthDesc = Desc) = spacer(width, 0f) +fun LayoutScope.spacer(height: Float, _desc: HeightDesc = Desc) = spacer(0f, height) +fun LayoutScope.spacer(width: UIComponent, height: UIComponent) = Spacer(100.percent boundTo width, 100.percent boundTo height)() +fun LayoutScope.spacer(width: UIComponent, _desc: WidthDesc = Desc) = Spacer(100.percent boundTo width, 0f.pixels)() +fun LayoutScope.spacer(height: UIComponent, _desc: HeightDesc = Desc) = Spacer(0f.pixels, 100.percent boundTo height)() + +sealed interface WidthDesc +sealed interface HeightDesc +private object Desc : WidthDesc, HeightDesc + +// How is this not in the stdlib? +internal inline fun Iterable.sumOf(selector: (T) -> Float): Float { + var sum = 0f + for (element in this) { + sum += selector(element) + } + return sum +} + +fun UIComponent.getChildModifier() = + effects + .filterIsInstance() + .map { it.childModifier } + .reduceOrNull { acc, it -> acc then it } + ?: Modifier + +fun UIComponent.addChildModifier(modifier: Modifier) { + enableEffect(ChildModifierMarker(modifier)) +} + +// Serves as a marker only. FIXME: integrate directly into the component class when we transition this DSL to Elementa? +private class ChildModifierMarker(val childModifier: Modifier) : Effect() \ No newline at end of file diff --git a/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/util/elementaExtensions.kt b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/util/elementaExtensions.kt new file mode 100644 index 0000000..86bc300 --- /dev/null +++ b/elementa/layoutdsl/src/main/kotlin/gg/essential/gui/util/elementaExtensions.kt @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.util + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.utils.ObservableAddEvent +import gg.essential.elementa.utils.ObservableClearEvent +import gg.essential.elementa.utils.ObservableList +import gg.essential.elementa.utils.ObservableRemoveEvent +import gg.essential.gui.common.onSetValueAndNow +import gg.essential.gui.elementa.state.v2.* +import gg.essential.gui.elementa.state.v2.collections.MutableTrackedList +import gg.essential.universal.UMouse +import gg.essential.universal.UResolution +import gg.essential.gui.elementa.state.v2.ListState as ListStateV2 +import gg.essential.gui.elementa.state.v2.State as StateV2 + +val UIComponent.hasWindow: Boolean + get() = this is Window || hasParent && parent.hasWindow + +fun UIComponent.pollingState(initialValue: T? = null, getter: () -> T): State { + val state = BasicState(initialValue ?: getter()) + enableEffect(object : Effect() { + override fun animationFrame() { + state.set(getter()) + } + }) + return state +} + +fun UIComponent.pollingStateV2(initialValue: T? = null, getter: () -> T): StateV2 { + val state = mutableStateOf(initialValue ?: getter()) + enableEffect(object : Effect() { + override fun animationFrame() { + state.set(getter()) + } + }) + return state +} + +fun UIComponent.layoutSafePollingState(initialValue: T? = null, getter: () -> T): StateV2 { + val state = mutableStateOf(initialValue ?: getter()) + enableEffect(object : Effect() { + override fun animationFrame() { + val window = Window.of(boundComponent) + // Start one-shot timer which will trigger immediately once the current `animationFrame` is complete + window.startTimer(0) { timerId -> + window.stopTimer(timerId) + + state.set(getter()) + } + } + }) + return state +} + +/** + * Creates a state that derives its value using the given [block]. The value of any state may be accessed within this + * block via [StateScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any + * one of them changes. + */ +@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("stateBy", "gg.essential.gui.elementa.state.v2.StateByKt.stateBy")) +fun stateBy(block: StateScope.() -> T): State { + val subscribed = mutableMapOf, () -> Unit>() + val observed = mutableSetOf>() + val scope = object : StateScope { + override fun State.invoke(): T { + observed.add(this) + return get() + } + } + + val result = BasicState(block(scope)) + + fun updateSubscriptions() { + observed.forEach { state -> + if (state !in subscribed) { + val unregister = state.onSetValue { + // FIXME this should really just run immediately but State is currently very prone to CME if you + // register or remove a listener while it its callback, so we need to delay here until that's fixed + Window.enqueueRenderOperation { + val newValue = block(scope) + updateSubscriptions() + result.set(newValue) + } + } + subscribed[state] = unregister + } + } + + subscribed.entries.removeAll { (state, unregister) -> + if (state !in observed) { + unregister() + true + } else { + false + } + } + + observed.clear() + } + updateSubscriptions() + + return result +} + +interface StateScope { + operator fun State.invoke(): T +} + +/** + * Executes the supplied [block] on this component's animationFrame + */ +fun UIComponent.onAnimationFrame(block: () -> Unit) = + enableEffect(object : Effect() { + override fun animationFrame() { + block() + } + }) + +/** + * Returns a state representing whether this UIComponent is hovered + * + * [hitTest] will perform a hit test to make sure the user is actually hovered over this component + * as compared to the mouse just being within its content bounds while being hovered over another + * component rendered above this. + * + * [layoutSafe] will delay the state change until a time in which it is safe to make layout changes. + * This option will induce an additional delay of one frame because the state is updated during the next + * [Window.enqueueRenderOperation] after the hoverState changes. + */ +fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true): State { + // "Unsafe" means that it is not safe to depend on this for layout changes + val unsafeHovered = BasicState(false) + + // "Safe" because layout changes can directly happen when this changes (ie in onSetValue) + val safeHovered = BasicState(false) + + // Performs a hit test based on the current mouse x / y + fun hitTestHovered(): Boolean { + // Positions the mouse in the center of pixels so isPointInside will + // pass for items 1 pixel wide objects. See ElementaVersion v2 for more details + val halfPixel = 0.5f / UResolution.scaleFactor.toFloat() + val mouseX = UMouse.Scaled.x.toFloat() + halfPixel + val mouseY = UMouse.Scaled.y.toFloat() + halfPixel + return if (isPointInside(mouseX, mouseY)) { + + val window = Window.of(this) + val hit = (window.hoveredFloatingComponent?.hitTest(mouseX, mouseY)) ?: window.hitTest(mouseX, mouseY) + + hit.isComponentInParentChain(this) || hit == this + } else { + false + } + } + + if (hitTest) { + // It's possible the animation framerate will exceed that of the actual frame rate + // Therefore, in order to avoid redundantly performing the hit test multiple times + // in the same frame, this boolean is used to ensure that hit testing is performed + // at most only a single time each frame + var registerHitTest = true + + onAnimationFrame { + if (registerHitTest) { + registerHitTest = false + Window.enqueueRenderOperation { + // The next animation frame should register another renderOperation + registerHitTest = true + + // It is possible that this component or a component in its parent tree + // was removed from the component tree between the last call to animationFrame + // and this evaluation in enqueueRenderOperation. If that is the case, we should not + // perform the hit test because it will throw an exception. + if (!this.isInComponentTree()) { + // Unset the hovered state because a component can no longer + // be hovered if it is not in the component tree + unsafeHovered.set(false) + return@enqueueRenderOperation + } + + // Since enqueueRenderOperation will keep polling the queue until there are no more items, + // the forwarding of any update to the safeHovered state will still happen this frame + unsafeHovered.set(hitTestHovered()) + } + } + } + } + onMouseEnter { + if (hitTest) { + unsafeHovered.set(hitTestHovered()) + } else { + unsafeHovered.set(true) + } + } + + onMouseLeave { + unsafeHovered.set(false) + } + + return if (layoutSafe) { + unsafeHovered.onSetValue { + Window.enqueueRenderOperation { + safeHovered.set(it) + } + } + safeHovered + } else { + unsafeHovered + } +} + +/** Marker effect for [makeHoverScope]/[hoverScope]. */ +private class HoverScope(val state: State) : Effect() + +/** + * This method declares this component and its children to be part of one hover scope. + * Whether any component inside a hover scope is considered "hovered" depends on whether the scope declares it as such. + * By default the scope is considered hovered based on the [hoveredState] of this component but this may be overridden + * by passing a custom non-null [state]. + * + * Scopes are resolved once on the first draw. As such they should be declared before the component is first drawn, + * cannot be removed, and are not updated if components are moved between different parents. + * + * If multiple scopes are nested, components within the inner scope will solely follow their direct parent scope and + * be completely oblivious to the outer scope. + * This can easily be customized by passing a different [state], e.g. passing + * `hoverScope(parentOnly = true) or hoveredState()` to make children appear as hovered when either the other or the + * inner scope is hovered. + * + * A hover scope may be re-declared on the same component to overwrite its source `state`. This allows a mostly + * self-contained component to declare a hover scope on itself by default; and if this default hover scope is not + * appropriate for some use case, the user may call `makeHoverScope` again on the component from the outside with a + * custom [state] (e.g. with `hoverScope(parentOnly = true)` to simply make it inherit from an outer scope as if it + * wasn't declared in the first place). + * Note that the same rules about first-time resolving still apply. + */ +fun UIComponent.makeHoverScope(state: State? = null) = apply { + removeEffect() + enableEffect(HoverScope(state ?: hoveredState())) +} + +fun UIComponent.makeHoverScope(state: StateV2) = makeHoverScope(state.toV1(this)) + +/** + * Receives the hover scope which this component is subject to. + * + * This method must not be called on components which are not part of any hover scope. + * + * @see [makeHoverScope] + */ +fun UIComponent.hoverScope(parentOnly: Boolean = false): State { + class HoverScopeConsumer : Effect() { + val state = BasicState(false) + + override fun setup() { + val sequence = if (parentOnly) parent.selfAndParents() else selfAndParents() + val scope = + sequence.firstNotNullOfOrNull { component -> + component.effects.firstNotNullOfOrNull { it as? HoverScope } + } ?: throw IllegalStateException("No hover scope found for ${this@hoverScope}.") + Window.enqueueRenderOperation { + scope.state.onSetValueAndNow { state.set(it) } + } + } + } + val consumer = HoverScopeConsumer() + enableEffect(consumer) + return consumer.state +} + +/** Once inherited, you can apply this to a component via [addTag] to be able to [findChildrenByTag]. */ +interface Tag + +/** Holder effect for a [Tag] */ +private class TagEffect(val tag: Tag) : Effect() + +/** Applies a [Tag] to this component. */ +fun UIComponent.addTag(tag: Tag) = apply { enableEffect(TagEffect(tag)) } + +/** Removes a [Tag] from this component. */ +fun UIComponent.removeTag(tag: Tag) = apply { effects.removeIf { it is TagEffect && it.tag == tag } } + +/** Returns a [Tag] of [T] which may or may not be attached to this component. */ +inline fun UIComponent.getTag(): T? = getTag(T::class.java) + +/** Returns a [Tag] of [T] which may or may not be attached to this component. */ +fun UIComponent.getTag(type: Class): T? { + val effect = effects.firstNotNullOfOrNull { + effect -> (effect as? TagEffect)?.takeIf { type.isInstance(it.tag) } + } ?: return null + + return type.cast(effect.tag) +} + +/** + * Searches for any children which contain a certain [Tag]. + * See [addTag] for applying a [Tag] to a component. + */ +fun UIComponent.findChildrenByTag(tag: Tag, recursive: Boolean = false) = findChildrenByTag(recursive) { it == tag } + +/** + * Finds any children which have a tag which matches the [predicate]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +inline fun UIComponent.findChildrenByTag( + recursive: Boolean = false, + noinline predicate: (T) -> Boolean = { true }, +) = findChildrenByTag(T::class.java, recursive, predicate) + +/** + * Returns a map of [UIComponent]s (children) to their [Tag]s of [T]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +inline fun UIComponent.findChildrenAndTags( + recursive: Boolean = false, + noinline predicate: (T) -> Boolean = { true }, +) = findChildrenAndTags(T::class.java, recursive, predicate) + +/** + * Finds any children which have a tag which matches the [predicate]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +fun UIComponent.findChildrenByTag( + type: Class, + recursive: Boolean = false, + predicate: (T) -> Boolean = { true } +): List { + val found = mutableListOf() + + fun addToFoundIfHasTag(component: UIComponent) { + for (child in component.children) { + val tag = child.getTag(type) + if (tag != null && predicate(tag)) { + found.add(child) + } + + if (recursive) { + addToFoundIfHasTag(child) + } + } + } + + addToFoundIfHasTag(this) + + return found +} + +/** + * Returns a map of [UIComponent]s (children) to their [Tag]s of [T]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +fun UIComponent.findChildrenAndTags( + type: Class, + recursive: Boolean = false, + predicate: (T) -> Boolean = { true } +): List> { + val found = mutableListOf>() + + fun addToFoundIfHasTag(component: UIComponent) { + for (child in component.children) { + val tag = child.getTag(type) + if (tag != null && predicate(tag)) { + found.add(child to tag) + } + + if (recursive) { + addToFoundIfHasTag(child) + } + } + } + + addToFoundIfHasTag(this) + + return found +} + +/** Returns a [Sequence] consisting of this component and its parents (including the Window) in that order. */ +fun UIComponent.selfAndParents() = + generateSequence(this) { if (it.parent != it) it.parent else null } + + +fun UIComponent.isComponentInParentChain(target: UIComponent): Boolean { + var component: UIComponent = this + while (component.hasParent && component !is Window) { + component = component.parent + if (component == target) + return true + } + + return false +} + +fun UIComponent.isInComponentTree(): Boolean = + this is Window || hasParent && this in parent.children && parent.isInComponentTree() + +fun ObservableList.onItemRemoved(callback: (E) -> Unit) { + addObserver { _, arg -> + if (arg is ObservableRemoveEvent<*>) { + callback(arg.element.value as E) + } + } +} + +fun ObservableList.onItemAdded(callback: (E) -> Unit) { + addObserver { _, arg -> + if (arg is ObservableAddEvent<*>) { + callback(arg.element.value as E) + } + } +} + +@Suppress("UNCHECKED_CAST") +fun ObservableList.toStateV2List(): ListStateV2 { + val stateList = mutableStateOf(MutableTrackedList(this.toMutableList())) + + this.addObserver { _, arg -> + when (arg) { + is ObservableAddEvent<*> -> stateList.add(arg.element.index, arg.element.value as E) + is ObservableClearEvent<*> -> stateList.clear() + is ObservableRemoveEvent<*> -> stateList.removeAt(arg.element.index) + } + } + + return stateList +} diff --git a/elementa/statev2/build.gradle.kts b/elementa/statev2/build.gradle.kts new file mode 100644 index 0000000..5259bfa --- /dev/null +++ b/elementa/statev2/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ + +import essential.universalLibs +import gg.essential.gradle.util.KotlinVersion +import gg.essential.gradle.util.setJvmDefault + +plugins { + kotlin("jvm") + id("gg.essential.defaults") +} + +universalLibs() + +dependencies { + implementation(kotlin("stdlib-jdk8", KotlinVersion.minimal.stdlib)) + implementation(project(":feature-flags")) +} + +// We need to use the compatibility mode on old versions because we used to use the old Kotlin defaults for those +// And while this isn't currently part of our ABI, once stuff migrates to Elementa, it will be, so we consider it now. +tasks.compileKotlin.setJvmDefault("all-compatibility") + +kotlin.jvmToolchain(8) + +tasks.compileKotlin { + kotlinOptions { + moduleName = "essential" + project.path.replace(':', '-').lowercase() + } +} \ No newline at end of file diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedList.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedList.kt new file mode 100644 index 0000000..b9445cd --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedList.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.collections + +import kotlin.collections.AbstractList + +/** + * An immutable List type that remembers the changes that have been applied to it, allowing one to very cheaply obtain + * a "diff" between its current and an older version via [getChangesSince]. + * + * To maintain good performance, the implementation assumes that the standard use case involves only a single chain + * of changes and that older lists are only ever compared to newer list, not read from directly. + * For this standard use case, it maintains performance characteristics similar to the regular [mutableListOf] in terms + * of memory and runtime. + * + * Non-standard use cases are supported but will generally have performance of `O(n+m)` where `n` is the size of the + * latest list and `m` is the amount of changes that have happened between this list and the latest list (i.e. only the + * latest list contains the full array of values, previous lists only contain a change and their successor list). + */ +class MutableTrackedList private constructor( + /** Counter increased with every change. Used to quickly determine which of two lists is older. */ + private val generation: Int, + realList: MutableList, +) : AbstractList(), TrackedList { + + private var maybeRealList: MutableList? = realList + private val realList: MutableList + get() = maybeRealList ?: computeRealList() + + private var nextList: MutableTrackedList? = null + private var nextDiff: Diff? = null + + /** Computes the real list for this list from the next list(s). */ + private fun computeRealList(): MutableList { + val generations = generateSequence(this) { if (it.maybeRealList != null) null else it.nextList }.toList() + val list = generations.last().realList.toMutableList() + for (i in generations.indices.reversed()) { + generations[i].nextDiff?.revert(list) + } + maybeRealList = list + return list + } + + /** Creates a child list based on this list with the given diff. */ + private fun fork( + diff: Diff, + child: MutableTrackedList = MutableTrackedList(generation + 1, realList), + ): MutableTrackedList { + + // Relinquish ownership of our real list, it now belongs to the child + maybeRealList = null + + // We only want to update our next pointer if we don't yet have one, otherwise we risk changing the result of + // future diff calls (compared to what they previously returned). + if (nextList == null) { + nextList = child + nextDiff = diff + } + + // Finally, apply the diff + diff.apply(child.realList) + + return child + } + + override fun getChangesSince(other: TrackedList): Sequence> { + return if (other is MutableTrackedList) { + getChangesSince(other) + } else { + TrackedList.Change.estimate(other, this).asSequence() + } + } + + fun getChangesSince(other: MutableTrackedList): Sequence> { + // Trivial case: no changes + if (other == this) { + return emptySequence() + } + + // Fast path: single diff only + if (other.nextList == this) { + return other.nextDiff!!.asChangeSequence() + } + + if (other.generation < this.generation) { + // Regular diff + val generations = generateSequence(other) { if (it == this) null else it.nextList }.toMutableList() + if (generations.removeLast() != this) return TrackedList.Change.estimate(other, this).asSequence() + return generations.asSequence().flatMap { it.nextDiff!!.asChangeSequence() } + } else { + // Reverse diff + val generations = generateSequence(this) { if (it == other) null else it.nextList }.toMutableList() + if (generations.removeLast() != other) return TrackedList.Change.estimate(this, other).asSequence() + return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() } + } + } + + constructor(mutableList: MutableList = mutableListOf()) : this(0, mutableList) + + override val size: Int + get() = realList.size + + override fun get(index: Int): E = realList[index] + + fun set(index: Int, element: E) = + fork(Diff.Multiple(listOf(Diff.Removal(index, realList[index]), Diff.Addition(index, element)))) + + fun add(element: E) = add(size, element) + fun add(index: Int, element: E) = fork(Diff.Addition(index, element)) + fun addAll(elements: Collection) = addAll(size, elements) + fun addAll(index: Int, elements: Collection) = fork(Diff.Multiple(elements.mapIndexed { i, e -> Diff.Addition(index + i, e) })) + + fun clear(): MutableTrackedList = fork(Diff.Clear(realList), MutableTrackedList(generation + 1, mutableListOf())) + + fun remove(element: E): MutableTrackedList { + val index = indexOf(element) + return if (index == -1) this else fork(Diff.Removal(index, element)) + } + fun removeAt(index: Int) = fork(Diff.Removal(index, this[index])) + fun removeAll(elements: Collection): MutableTrackedList { + val diffs = elements.mapNotNull { element -> + val index = indexOf(element) + if (index == -1) null else Diff.Removal(index, element) + }.sortedBy { -it.index } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + fun retainAll(elements: Collection): MutableTrackedList { + val diffs = realList.mapIndexedNotNull { index, element -> + if (element in elements) null else Diff.Removal(index, element) + }.reversed() + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun applyChanges(changes: List>): MutableTrackedList { + if (changes.isEmpty()) return this + return fork(changes.map { + when (it) { + is TrackedList.Add -> Diff.Addition(it.element.index, it.element.value) + is TrackedList.Remove -> Diff.Removal(it.element.index, it.element.value) + is TrackedList.Clear -> Diff.Clear(it.oldElements.toList()) + } + }.let { it.singleOrNull() ?: Diff.Multiple(it) }) + } + + private sealed interface Diff { + fun apply(list: MutableList) + fun revert(list: MutableList) + fun asChangeSequence(): Sequence> + fun asInverseChangeSequence(): Sequence> + + data class Addition(val index: Int, val element: E) : Diff { + override fun apply(list: MutableList) { + list.add(index, element) + } + + override fun revert(list: MutableList) { + list.removeAt(index) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedList.Add(IndexedValue(index, element))) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedList.Remove(IndexedValue(index, element))) + } + + data class Removal(val index: Int, val element: E) : Diff { + override fun apply(list: MutableList) { + list.removeAt(index) + } + + override fun revert(list: MutableList) { + list.add(index, element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedList.Remove(IndexedValue(index, element))) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedList.Add(IndexedValue(index, element))) + } + + data class Clear(val oldList: List) : Diff { + override fun apply(list: MutableList) { + list.clear() + } + + override fun revert(list: MutableList) { + list.addAll(oldList) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedList.Clear(oldList)) + + override fun asInverseChangeSequence(): Sequence> = + oldList.withIndex().asSequence().map { TrackedList.Add(it) } + } + + data class Multiple(val diffs: List>) : Diff { + override fun revert(list: MutableList) { + for (i in diffs.indices.reversed()) { + diffs[i].revert(list) + } + } + + override fun apply(list: MutableList) { + for (change in diffs) { + change.apply(list) + } + } + + override fun asChangeSequence(): Sequence> = + diffs.asSequence().flatMap { it.asChangeSequence() } + + override fun asInverseChangeSequence(): Sequence> = + diffs.asReversed().asSequence().flatMap { it.asInverseChangeSequence() } + } + } +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedSet.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedSet.kt new file mode 100644 index 0000000..f9ddbde --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/MutableTrackedSet.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.collections + +/** + * An immutable Set type that remembers the changes that have been applied to it, allowing one to very cheaply obtain + * a "diff" between its current and an older version via [getChangesSince]. + * + * To maintain good performance, the implementation assumes that the standard use case involves only a single chain + * of changes and that older sets are only ever compared to newer sets, not read from directly. + * For this standard use case, it maintains performance characteristics similar to the regular [mutableSetOf] in terms + * of memory and runtime. + * + * Non-standard use cases are supported but will generally have performance of `O(n+m)` where `n` is the size of the + * latest set and `m` is the amount of changes that have happened between this set and the latest set (i.e. only the + * latest set contains the full array of values, previous sets only contain a change and their successor set). + * + * In the standard use case, the iteration order for the latest version matches insertion order. For all other use cases + * and versions, iteration order is undefined. + */ +class MutableTrackedSet private constructor( + /** Counter increased with every change. Used to quickly determine which of two sets is older. */ + private val generation: Int, + realSet: MutableSet, +) : AbstractSet(), TrackedSet { + + private var maybeRealSet: MutableSet? = realSet + private val realSet: MutableSet + get() = maybeRealSet ?: computeRealSet() + + private var nextSet: MutableTrackedSet? = null + private var nextDiff: Diff? = null + + /** Computes the real set for this set from the next set(s). */ + private fun computeRealSet(): MutableSet { + val generations = generateSequence(this) { if (it.maybeRealSet != null) null else it.nextSet }.toList() + val set = generations.last().realSet.toMutableSet() + for (i in generations.indices.reversed()) { + generations[i].nextDiff?.revert(set) + } + maybeRealSet = set + return set + } + + /** Creates a child set based on this set with the given diff. */ + private fun fork( + diff: Diff, + child: MutableTrackedSet = MutableTrackedSet(generation + 1, realSet), + ): MutableTrackedSet { + + // Relinquish ownership of our real set, it now belongs to the child + maybeRealSet = null + + // We only want to update our next pointer if we don't yet have one, otherwise we risk changing the result of + // future diff calls (compared to what they previously returned). + if (nextSet == null) { + nextSet = child + nextDiff = diff + } + + // Finally, apply the diff + diff.apply(child.realSet) + + return child + } + + override fun getChangesSince(other: TrackedSet): Sequence> { + return if (other is MutableTrackedSet) { + getChangesSince(other) + } else { + TrackedSet.Change.estimate(other, this).asSequence() + } + } + + fun getChangesSince(other: MutableTrackedSet): Sequence> { + // Trivial case: no changes + if (other == this) { + return emptySequence() + } + + // Fast path: single diff only + if (other.nextSet == this) { + return other.nextDiff!!.asChangeSequence() + } + + if (other.generation < this.generation) { + // Regular diff + val generations = generateSequence(other) { if (it == this) null else it.nextSet }.toMutableList() + if (generations.removeLast() != this) return TrackedSet.Change.estimate(other, this).asSequence() + return generations.asSequence().flatMap { it.nextDiff!!.asChangeSequence() } + } else { + // Reverse diff + val generations = generateSequence(this) { if (it == other) null else it.nextSet }.toMutableList() + if (generations.removeLast() != other) return TrackedSet.Change.estimate(this, other).asSequence() + return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() } + } + } + + constructor(mutableSet: MutableSet = mutableSetOf()) : this(0, mutableSet) + + override val size: Int + get() = realSet.size + + override fun iterator(): Iterator = realSet.iterator() + override fun contains(element: E): Boolean = realSet.contains(element) + + fun add(element: E) = if (element in this) this else fork(Diff.Addition(element)) + fun addAll(elements: Collection): MutableTrackedSet { + val diffs = elements.mapNotNull { element -> + if (element in this) null else Diff.Addition(element) + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun clear(): MutableTrackedSet = fork(Diff.Clear(realSet), MutableTrackedSet(generation + 1, mutableSetOf())) + + fun remove(element: E) = if (element in this) fork(Diff.Removal(element)) else this + fun removeAll(elements: Collection): MutableTrackedSet { + val diffs = elements.mapNotNull { element -> + if (element in this) Diff.Removal(element) else null + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + fun retainAll(elements: Collection): MutableTrackedSet { + val diffs = realSet.mapNotNull { element -> + if (element in elements) null else Diff.Removal(element) + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun applyChanges(changes: List>): MutableTrackedSet { + if (changes.isEmpty()) return this + return fork(changes.map { + when (it) { + is TrackedSet.Add -> Diff.Addition(it.element) + is TrackedSet.Remove -> Diff.Removal(it.element) + is TrackedSet.Clear -> Diff.Clear(it.oldElements.toSet()) + } + }.let { it.singleOrNull() ?: Diff.Multiple(it) }) + } + + private sealed interface Diff { + fun apply(set: MutableSet) + fun revert(set: MutableSet) + fun asChangeSequence(): Sequence> + fun asInverseChangeSequence(): Sequence> + + data class Addition(val element: E) : Diff { + override fun apply(set: MutableSet) { + set.add(element) + } + + override fun revert(set: MutableSet) { + set.remove(element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Add(element)) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Remove(element)) + } + + data class Removal(val element: E) : Diff { + override fun apply(set: MutableSet) { + set.remove(element) + } + + override fun revert(set: MutableSet) { + set.add(element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Remove(element)) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Add(element)) + } + + data class Clear(val oldSet: Set) : Diff { + override fun apply(set: MutableSet) { + set.clear() + } + + override fun revert(set: MutableSet) { + set.addAll(oldSet) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Clear(oldSet)) + + override fun asInverseChangeSequence(): Sequence> = + oldSet.asSequence().map { TrackedSet.Add(it) } + } + + data class Multiple(val diffs: List>) : Diff { + override fun revert(set: MutableSet) { + for (i in diffs.indices.reversed()) { + diffs[i].revert(set) + } + } + + override fun apply(set: MutableSet) { + for (change in diffs) { + change.apply(set) + } + } + + override fun asChangeSequence(): Sequence> = + diffs.asSequence().flatMap { it.asChangeSequence() } + + override fun asInverseChangeSequence(): Sequence> = + diffs.asReversed().asSequence().flatMap { it.asInverseChangeSequence() } + } + } +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedList.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedList.kt new file mode 100644 index 0000000..c3d0b63 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedList.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.collections + +/** + * An immutable List type that remembers the changes that have been applied to construct it, allowing one to very + * cheaply obtain a "diff" between it and one of the lists it has been constructed from. + * + * The exact meaning of "very cheaply" may differ depending on the specific implementation but should never be worse + * than `O(n+m)` where `n` is the size of both lists and `m` is the amount of changes that have happened between + * two lists. In the best case it should just be `O(m)`. + * + * If two unrelated tracked lists are compared with each other, the result will usually just equal [Change.estimate], + * which takes `O(n)`. + * + * Beware that even though lists of this type appear to be immutable, they are not guaranteed to be internally immutable + * (for performance reasons) and as such are not generally thread-safe. + */ +interface TrackedList : List { + /** Returns changes one would have to apply to [other] to obtain `this`. */ + fun getChangesSince(other: TrackedList<@UnsafeVariance E>): Sequence> + + data class Add(val element: IndexedValue) : Change + data class Remove(val element: IndexedValue) : Change + data class Clear(val oldElements: List) : Change + + sealed interface Change { + companion object { + /** + * Estimates the changes one would have to apply to [oldList] to obtain [newList]. + * + * Note that while the estimate is correct (i.e. the changes will result in [newList]), it is not + * necessarily minimal (i.e. there may be a shorter list of changes that would also result in [newList]), + * nor is it accurate (i.e. even if both arguments are [MutableTrackedList]s, the returned changes may + * differ from how those lists were actually created). + * + * The result is however minimal if only one of additions, removals, or updates (`set`) were applied between + * [oldList] and [newList]. It may also be minimal if a mix of these was applied but no guarantees are made + * in that case. + */ + fun estimate(oldList: List, newList: List): List> { + return if (newList.isEmpty()) { + if (oldList.isEmpty()) { + emptyList() + } else { + listOf(Clear(oldList)) + } + } else { + val changes = mutableListOf>() + + var oldIndex = 0 + var newIndex = 0 + + while (oldIndex <= oldList.lastIndex && newIndex <= newList.lastIndex) { + val oldValue = oldList[oldIndex] + val newValue = newList[newIndex] + if (oldValue == newValue) { + oldIndex++ + newIndex++ + continue + } + if (newList.size == oldList.size) { + changes.add(Remove(IndexedValue(newIndex, oldValue))) + changes.add(Add(IndexedValue(newIndex, newValue))) + oldIndex++ + newIndex++ + } else if (newList.size - newIndex > oldList.size - oldIndex) { + changes.add(Add(IndexedValue(newIndex, newValue))) + newIndex++ + } else { + changes.add(Remove(IndexedValue(newIndex, oldValue))) + oldIndex++ + } + } + + while (newIndex <= newList.lastIndex) { + changes.add(Add(IndexedValue(newIndex, newList[newIndex]))) + newIndex++ + } + + while (oldIndex <= oldList.lastIndex) { + changes.add(Remove(IndexedValue(newIndex, oldList[oldIndex]))) + oldIndex++ + } + + changes + } + } + } + } +} \ No newline at end of file diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedSet.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedSet.kt new file mode 100644 index 0000000..24651d7 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/TrackedSet.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.collections + +/** + * An immutable Set type that remembers the changes that have been applied to construct it, allowing one to very + * cheaply obtain a "diff" between it and one of the sets it has been constructed from. + * + * The exact meaning of "very cheaply" may differ depending on the specific implementation but should never be worse + * than `O(n+m)` where `n` is the size of both sets and `m` is the amount of changes that have happened between + * two sets. In the best case it should just be `O(m)`. + * + * If two unrelated tracked sets are compared with each other, the result will usually just equal [Change.estimate], + * which takes `O(n)`. + * + * Beware that even though sets of this type appear to be immutable, they are not guaranteed to be internally immutable + * (for performance reasons) and as such are not generally thread-safe. + */ +interface TrackedSet : Set { + /** Returns changes one would have to apply to [other] to obtain `this`. */ + fun getChangesSince(other: TrackedSet<@UnsafeVariance E>): Sequence> + + data class Add(val element: E) : Change + data class Remove(val element: E) : Change + data class Clear(val oldElements: Set) : Change + + sealed interface Change { + companion object { + /** + * Estimates the changes one would have to apply to [oldSet] to obtain [newSet]. + */ + fun estimate(oldSet: Set, newSet: Set): List> { + return if (newSet.isEmpty()) { + if (oldSet.isEmpty()) { + emptyList() + } else { + listOf(Clear(oldSet)) + } + } else { + val changes = mutableListOf>() + + for (newValue in newSet) { + if (newValue !in oldSet) { + changes.add(Add(newValue)) + } + } + for (oldValue in oldSet) { + if (oldValue !in newSet) { + changes.add(Remove(oldValue)) + } + } + + changes + } + } + } + } +} \ No newline at end of file diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/utils.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/utils.kt new file mode 100644 index 0000000..7b70f65 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/collections/utils.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.collections + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.gui.elementa.state.v2.ListState + +// FIXME this is assuming there are no duplicate keys (good enough for now) +fun ListState.asMap(owner: ReferenceHolder, block: (T) -> Pair): Map { + var oldList = get() + val map = oldList.associateTo(mutableMapOf(), block) + val keys = map.keys.toMutableList() + onSetValue(owner) { newList -> + val changes = newList.getChangesSince(oldList).also { oldList = newList } + for (change in changes) { + when (change) { + is TrackedList.Add -> { + val (k, v) = block(change.element.value) + keys.add(change.element.index, k) + map[k] = v + } + is TrackedList.Remove -> { + map.remove(keys.removeAt(change.element.index)) + } + is TrackedList.Clear -> { + map.clear() + keys.clear() + } + } + } + } + return map +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/color/color.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/color/color.kt new file mode 100644 index 0000000..f20c167 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/color/color.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.color + +import gg.essential.elementa.constraints.ColorConstraint +import gg.essential.elementa.dsl.basicColorConstraint +import gg.essential.gui.elementa.state.v2.State +import java.awt.Color + +fun State.toConstraint() = basicColorConstraint { get() } + +val State.constraint: ColorConstraint + get() = toConstraint() diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/booleans.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/booleans.kt new file mode 100644 index 0000000..6093a42 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/booleans.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.combinators + +import gg.essential.gui.elementa.state.v2.MutableState +import gg.essential.gui.elementa.state.v2.State + +infix fun State.and(other: State) = + zip(other) { a, b -> a && b } + +infix fun State.or(other: State) = + zip(other) { a, b -> a || b } + +operator fun State.not() = map { !it } + +operator fun MutableState.not() = bimap({ !it }, { !it }) diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/pair.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/pair.kt new file mode 100644 index 0000000..fb6fb25 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/pair.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.combinators + +import gg.essential.gui.elementa.state.v2.State + +operator fun State>.component1(): State = this.map { it.first } +operator fun State>.component2(): State = this.map { it.second } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/state.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/state.kt new file mode 100644 index 0000000..21f0335 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/state.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.combinators + +import gg.essential.gui.elementa.state.v2.MutableState +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.memo + +/** Maps this state into a new state */ +fun State.map(mapper: (T) -> U): State { + return memo { mapper(get()) } +} + +/** Maps this mutable state into a new mutable state. */ +fun MutableState.bimap(map: (T) -> U, unmap: (U) -> T): MutableState { + return object : MutableState, State by this.map(map) { + override fun set(mapper: (U) -> U) { + this@bimap.set { unmap(mapper(map(it))) } + } + } +} + +/** Zips this state with another state */ +fun State.zip(other: State): State> = zip(other, ::Pair) + +/** Zips this state with another state using [mapper] */ +fun State.zip(other: State, mapper: (T, U) -> V): State { + return memo { mapper(this@zip(), other()) } +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/strings.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/strings.kt new file mode 100644 index 0000000..07c86fd --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/strings.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.combinators + +import gg.essential.gui.elementa.state.v2.State + +fun State.contains(other: State, ignoreCase: Boolean = false) = + zip(other) { a, b -> a.contains(b, ignoreCase) } + +fun State.isEmpty() = map { it.isEmpty() } + +fun State.isNotEmpty() = map { it.isNotEmpty() } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/utils.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/utils.kt new file mode 100644 index 0000000..b5176e6 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/combinators/utils.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.combinators + +import gg.essential.gui.elementa.state.v2.MutableState + +fun MutableState.reorder(vararg mapping: Int) = + bimap({ mapping[it] }, { mapping.indexOf(it) }) diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/compatibility.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/compatibility.kt new file mode 100644 index 0000000..8e6b825 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/compatibility.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import java.util.function.Consumer +import gg.essential.elementa.state.State as V1State + +private class V2AsV1State(private val v2State: State, owner: ReferenceHolder) : V1State() { + // Stored in a field, so the listener is kept alive at least as long as this legacy state instance exists + private val listener: (T) -> Unit = { super.set(it) } + + init { + v2State.onSetValue(owner, listener) + } + + override fun get(): T = v2State.get() + + override fun set(value: T) { + if (v2State is MutableState<*>) { + (v2State as MutableState).set { value } + } else { + super.set(value) + } + } +} + +/** + * Converts this state into a v1 [State][V1State]. + * + * If [V1State.set] is called on the returned state and this value is a [MutableState], then the call is forwarded to + * [MutableState.set], otherwise only the internal field of the v1 state will be updated (and overwritten again the next + * time this state changes; much like the old mapped states). + * + * Note that as with any listener on a v2 state, the returned v1 state may be garbage collected once there are no more + * strong references to it. This v2 state will not by itself keep it alive. + * The [owner] argument serves to prevent this from happening too early, see [State.onSetValue]. + */ +fun State.toV1(owner: ReferenceHolder): V1State = V2AsV1State(this, owner) + +/** + * Converts this state into a v2 [MutableState]. + * + * The returned state is registered as a listener on the v1 state and as such will live as long as the v1 state. + * This matches v1 state behavior. If this is not desired, stop using v1 state. + */ +fun V1State.toV2(): MutableState { + val referenceHolder = ReferenceHolderImpl() + val v1 = this + val v2 = mutableStateOf(get()) + + v2.onSetValue(referenceHolder) { value -> + if (v1.get() != value) { + v1.set(value) + } + } + v1.onSetValue(object : Consumer { + @Suppress("unused") // keep this alive for as long as the v1 state + val referenceHolder = referenceHolder + + override fun accept(value: T) { + v2.set(value) + } + }) + + return v2 +} + +/** + * Returns a delegating state with internal mutability. That is, the value of the returned state generally follows the + * value of the input state (or the state passed to [DelegatingState.rebind]), but [MutableState.set] is not forwarded + * to the bound state. Instead the new value is stored internally and returned until the input state changes again, at + * which point it'll be overwritten again. + * + * Using such a state (`input.map { it }`) with a `rebindState` method and direct getter+setter methods for the state + * content was a common anti-pattern used in many places throughout Element. + * To preserve backwards compatibility for this behavior, this method exists to quickly construct such a state in the v2 + * world. + * New code should instead just use a regular delegating state and have the setter rebind it to a new immutable state. + */ +internal fun State.wrapWithDelegatingMutableState(): MutableDelegatingState { + val delegatingState = stateDelegatingTo(this) + val derivedState = + derivedState(get()) { owner, derivedState -> + delegatingState.onSetValue(owner) { derivedState.set(it) } + } + // Note: this in an implementation detail of `derivedState`, do not rely on it outside of Elementa + val mutableState = derivedState as MutableState + + return object : DelegatingState, MutableState by mutableState, MutableDelegatingState { + override fun rebind(newState: State) { + delegatingState.rebind(newState) + } + } +} + +internal interface MutableDelegatingState : DelegatingState, MutableState diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/coroutine.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/coroutine.kt new file mode 100644 index 0000000..52ba6a7 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/coroutine.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** Waits until this [State] has a value which [equals] the given [value]. */ +suspend fun State.awaitValue(value: T): T = await { it == value } + +/** Waits until this [State] has a value for which [accept] returns `true` and returns that value. */ +suspend fun State.await(accept: (T) -> Boolean): T { + // Fast-path + get().let { if (accept(it)) return it } + + // Slow path + return suspendCancellableCoroutine { continuation -> + lateinit var unregister: () -> Unit + var listener: ((T) -> Unit)? + listener = { value -> + if (accept(value)) { + unregister() + continuation.resume(value) + } + } + unregister = onSetValue(ReferenceHolder.Weak, listener) + listener(get()) + continuation.invokeOnCancellation { + // Note: we cannot call `unregister` here because `invokeOnCancellation` makes no guarantee about which + // thread we run on, and `unregister` isn't thread safe. + // So we'll instead merely drop our reference to the listener and leave it to State's weakness properties + // to clean up the registration. + // This does mean our callback will continue to be invoked, but `CancellableCoroutine` is fine with that + // because cancellation may race with `resume` in pretty much any code. + listener = null + } + } +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/flatten.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/flatten.kt new file mode 100644 index 0000000..0ac3fc9 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/flatten.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +fun State>.flatten() = stateBy { this@flatten()() } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/Impl.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/Impl.kt new file mode 100644 index 0000000..9f0d8c3 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/Impl.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.impl + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.gui.elementa.state.v2.DelegatingMutableState +import gg.essential.gui.elementa.state.v2.DelegatingState +import gg.essential.gui.elementa.state.v2.MutableState +import gg.essential.gui.elementa.state.v2.Observer +import gg.essential.gui.elementa.state.v2.ReferenceHolderImpl +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.mutableStateOf + +internal interface Impl { + fun mutableState(value: T): MutableState + fun memo(func: Observer.() -> T): State + fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit + + fun stateDelegatingTo(state: State): DelegatingState = + object : DelegatingState { + private val target = mutableStateOf(state) + override fun rebind(newState: State) = target.set(newState) + override fun Observer.get(): T = target()() + } + + fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = + object : DelegatingMutableState { + private val target = mutableStateOf(state) + override fun set(mapper: (T) -> T) = target.getUntracked().set(mapper) + override fun rebind(newState: MutableState) = target.set(newState) + override fun Observer.get(): T = target()() + } + + fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, + ): State = + object : State { + val referenceHolder = ReferenceHolderImpl() // keep this alive for at least as long as the returned state + val derivedState = mutableStateOf(initialValue) + init { + builder(referenceHolder, derivedState) + } + + override fun Observer.get(): T = derivedState() + } +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/basic/impl.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/basic/impl.kt new file mode 100644 index 0000000..52e7ec5 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/basic/impl.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.impl.basic + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.gui.elementa.state.v2.MutableState +import gg.essential.gui.elementa.state.v2.Observer +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * Semi-lazy node graph implementation. + * + * The actual code is extremely similar to [gg.essential.gui.elementa.state.v2.impl.minimal.MarkThenPullImpl] (literally + * only a single line difference), however the mechanism by which it functions is not. + * The code has been duplicated, so we continue to have a simple reference implementation even when this implementation + * evolves further. + * + * This implementation operates in three phases: + * - The first phase propagates a may-be-dirty state to all potentially affected nodes + * - The second phase goes through all dirty nodes and run the third phase for each of them + * - The phase phase checks if the given node needs to be updated, recursively. And if so, updates it, marks all its + * direct dependents as dirty (to be processed by the second phase), and then returns to the second phase. + * + * Unlike [gg.essential.gui.elementa.state.v2.impl.minimal.MarkThenPullImpl], this means that sub-graphs which are + * potentially affected but whose dependencies have not actually changed, will not be visited (more than once per + * them actually changing; as opposed to having to re-visit every time they are potentially affected). + * That does mean that this implementation will in exchange potentially visit intermediate nodes which do not actually + * have any effects attached to them any more (hence it only being "semi lazy"). + * However, in practice, non-affected nodes usually vastly outnumber dead intermediate nodes (especially because + * those are usually garbage collected together with the respective effects that used them) by one to two orders of + * magnitude, making this well worth it. + */ +internal object MarkThenPushAndPullImpl : Impl { + override fun mutableState(value: T): MutableState { + val node = Node(NodeKind.Mutable, NodeState.Clean, UNREACHABLE, value) + return object : State by node, MutableState { + override fun set(mapper: (T) -> T) { + node.set(mapper(node.getUntracked())) + } + } + } + + override fun memo(func: Observer.() -> T): State = + Node(NodeKind.Memo, NodeState.Dirty, func, null) + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + val node = Node(NodeKind.Effect, NodeState.Dirty, func, Unit) + node.update(Update.get()) + val refCleanup = referenceHolder.holdOnto(node) + return { + node.cleanup() + refCleanup() + } + } + +} + +private enum class NodeKind { + /** + * A leaf node which represents a manually updated value which only changes when [Node.set] is invoked. + * It does not have any dependencies nor a [Node.func]. + */ + Mutable, + + /** + * An intermediate node which is lazily computed and lazily updated via [Node.func]. + * May have any number of both dependencies and dependents. + */ + Memo, + + /** + * A node which represents the root of a dependency tree. + * It does not have any dependents and does not produce any value. + * + * Unlike [Memo], it is not lazy and will be updated when any of its dependencies change. + * If any of its dependencies are lazy, they too will be updated as necessary for this node to obtain a complete + * view of up-to-date values. + */ + Effect, +} + +private enum class NodeState { + /** + * The [Node.value] is up-to-date. + * For [NodeKind.Effect], the [Node.func] has been run with the latest values. + */ + Clean, + + /** + * Some of the node's dependencies, including transitive one, may be [Dirty] and need to be checked. + */ + ToBeChecked, + + /** + * The [Node.value] is outdated and needs to be re-evaluated. + * For [NodeKind.Effect], the [Node.func] needs to be re-run. + */ + Dirty, + + /** + * The node has been disposed off and should no longer be updated. + */ + Dead, +} + +private class Node( + val kind: NodeKind, + private var state: NodeState, + private val func: Observer.() -> T, + private var value: T?, +) : State, Observer { + private val observed = mutableSetOf>() + private val dependencies = mutableListOf>() + private val dependents: MutableList>> = mutableListOf() + + override fun Observer.get(): T { + return getTracked(this@get) + } + + fun getTracked(observer: Observer): T { + if (observer is Node<*>) { + observer.observed.add(this) + } + return getUntracked() + } + + override fun getUntracked(): T { + if (state != NodeState.Clean) { + update(Update.get()) + } + @Suppress("UNCHECKED_CAST") + return value as T + } + + fun set(newValue: T) { + assert(kind == NodeKind.Mutable) + + if (value == newValue) { + return + } + + value = newValue + + val update = Update.get() + for (dep in dependents.iter()) { + dep.markDirty(update) + } + update.flush() + } + + private fun mark(update: Update, newState: NodeState) { + val oldState = state + if (oldState.ordinal >= newState.ordinal) { + return + } + + if (newState == NodeState.Dirty) { + update.queueNode(this) + } + + state = newState + } + + private fun markDirty(update: Update) { + mark(update, NodeState.Dirty) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + private fun markToBeChecked(update: Update) { + if (state != NodeState.Clean) return + + mark(update, NodeState.ToBeChecked) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + fun update(update: Update) { + if (state == NodeState.Clean) { + return + } + + if (state == NodeState.ToBeChecked) { + for (dep in dependencies) { + dep.update(update) + if (state == NodeState.Dirty) { + break + } + } + } + + if (state == NodeState.Dirty) { + val newValue = func(this) + + if (state == NodeState.Dead) { + return + } + + for (i in dependencies.indices.reversed()) { + val dep = dependencies[i] + if (dep !in observed) { + dependencies.removeAt(i) + dep.removeDependent(this) + } + } + for (dep in observed) { + if (dep !in dependencies) { + dependencies.add(dep) + dep.addDependent(this) + } + } + observed.clear() + + if (value != newValue) { + value = newValue + + for (dep in dependents.iter()) { + dep.mark(update, NodeState.Dirty) + } + } + } + + state = NodeState.Clean + } + + fun cleanup() { + for (dep in dependencies) { + dep.removeDependent(this) + } + dependencies.clear() + + state = NodeState.Dead + } + + private var referenceQueueField: ReferenceQueue>? = null + private val referenceQueue: ReferenceQueue> + get() = referenceQueueField ?: ReferenceQueue>().also { referenceQueueField = it } + + private fun addDependent(node: Node<*>) { + cleanupStaleReferences() + dependents.add(WeakReference(node, referenceQueue)) + } + + private fun removeDependent(node: Node<*>) { + val index = dependents.indexOfFirst { it.get() == node } + if (index >= 0) { + dependents.removeAt(index) + } + } + + private fun cleanupStaleReferences() { + val queue = referenceQueueField ?: return + + if (queue.poll() == null) { + return + } + + @Suppress("ControlFlowWithEmptyBody") + while (queue.poll() != null); + + dependents.removeIf { it.get() == null } + } + + private fun MutableList>>.iter(): Iterator> { + return asSequence().mapNotNull { it.get() }.iterator() + } +} + +private class Update { + private var queue: MutableList> = mutableListOf() + private var processing: Boolean = false + + fun queueNode(node: Node<*>) { + queue.add(node) + } + + fun flush() { + if (processing || queue.isEmpty()) { + return + } + + processing = true + try { + var i = 0 + while (true) { + val node = queue.getOrNull(i) ?: break + node.update(this) + i++ + } + queue.clear() + } finally { + processing = false + } + } + + companion object { + private val INSTANCE = ThreadLocal.withInitial { Update() } + fun get(): Update = INSTANCE.get() + } +} + +private val UNREACHABLE: Observer.() -> Nothing = { error("unreachable") } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/legacy/impl.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/legacy/impl.kt new file mode 100644 index 0000000..10d96cd --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/legacy/impl.kt @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.impl.legacy + +import gg.essential.config.AccessedViaReflection +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.gui.elementa.state.v2.DelegatingMutableState +import gg.essential.gui.elementa.state.v2.DelegatingState +import gg.essential.gui.elementa.state.v2.MutableState +import gg.essential.gui.elementa.state.v2.Observer +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** Legacy implementation based around `onSetValue` which makes no attempt at being glitch-free. */ +internal object LegacyImpl : Impl { + override fun mutableState(value: T): MutableState = BasicState(value) + + override fun memo(func: Observer.() -> T): State { + val subscribed = mutableMapOf, () -> Unit>() + val observed = mutableSetOf>() + val scope = ObserverImpl(observed) + + return derivedState(initialValue = func(scope)) { owner, derivedState -> + fun updateSubscriptions() { + for (state in observed) { + if (state in subscribed) continue + + subscribed[state] = state.onSetValue(owner) { + val newValue = func(scope) + updateSubscriptions() + derivedState.set(newValue) + } + } + + subscribed.entries.removeAll { (state, unregister) -> + if (state !in observed) { + unregister() + true + } else { + false + } + } + + observed.clear() + } + updateSubscriptions() + } + } + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + var disposed = false + val release = referenceHolder.holdOnto(memo { + if (disposed) return@memo + func() + }) + return { + disposed = true + release() + } + } + + override fun stateDelegatingTo(state: State): DelegatingState = DelegatingStateImpl(state) + + override fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = DelegatingMutableStateImpl(state) + + override fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit + ): State = ReferenceHoldingBasicState(initialValue).apply { builder(this, this) } +} + +private class ObserverImpl(val observed: MutableSet>) : Observer + +/** A simple implementation of [MutableState], containing only a backing field */ +private open class BasicState(private var valueBacker: T) : MutableState { + private val referenceQueue = ReferenceQueue() + private val listeners = mutableListOf>() + + /** + * Contains the size of the [listeners] list which we currently iterate over. + * We must not directly modify these entries as that may mess up the iteration, anything after those entries is fair + * game though. + * Additions always happen at the end of the list, so those are trivial. + * For removals we instead set the [ListenerEntry.removed] flag and let the iteration code clean up the entry when + * it passes over it. + * We can't solely rely on that for all cleanup because we only iterate the listener list when the value of the state + * changes, so if it doesn't, we need to clean up entries immediately. + */ + private var liveSize = 0 + + override fun Observer.get(): T { + (this@get as? ObserverImpl)?.observed?.add(this@BasicState) + return getUntracked() + } + + override fun getUntracked(): T = valueBacker + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + return ListenerEntry(this, listener, ownerCallback).also { listeners.add(it) } + } + + override fun set(mapper: (T) -> T) { + val oldValue = valueBacker + val newValue = mapper(oldValue) + if (oldValue == newValue) { + return + } + + valueBacker = newValue + + // Iterate over listeners while allowing for concurrent add to the end of the list (newly added entries will not get + // called) and concurrent remove from anywhere in the list (via `removed` flag in each entry, or directly for newly + // added listeners). See [liveSize] docs. + liveSize = listeners.size + var i = 0 + while (i < liveSize) { + val entry = listeners[i] + if (entry.removed) { + listeners.removeAt(i) + liveSize-- + } else { + entry.get()?.invoke(newValue) + i++ + } + } + liveSize = 0 + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: BasicState, + listenerCallback: (T) -> Unit, + private val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + var removed = false + + override fun invoke() { + // If we do not currently iterate over the listener list, we can directly remove this entry from the list, + // otherwise we merely mark it as deleted and let the iteration code take care of it. + val index = state.listeners.indexOf(this@ListenerEntry) + if (index >= state.liveSize) { + state.listeners.removeAt(index) + } else { + removed = true + } + + ownerCallback.get()?.invoke() + } + } +} + +/** Base class for implementations of Delegating(Mutable)State classes. */ +private open class DelegatingStateBase>(protected var delegate: S) : State { + private val referenceQueue = ReferenceQueue() + private var listeners = mutableListOf>() + + override fun Observer.get(): T { + (this@get as? ObserverImpl)?.observed?.add(this@DelegatingStateBase) + return getUntracked() + } + + override fun getUntracked(): T = delegate.get() + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + val removeCallback = delegate.onSetValue(ReferenceHolder.Weak, listener) + return ListenerEntry(this, listener, removeCallback, ownerCallback).also { listeners.add(it) } + } + + @AccessedViaReflection("DelegatingStateBase") + fun rebind(newState: S) { + val oldState = delegate + if (oldState == newState) { + return + } + + delegate = newState + + listeners = + listeners.mapNotNullTo(mutableListOf()) { entry -> + entry.removeCallback() + val listenerCallback = entry.get() ?: return@mapNotNullTo null + val removeCallback = newState.onSetValue(ReferenceHolder.Weak, listenerCallback) + ListenerEntry(this, listenerCallback, removeCallback, entry.ownerCallback) + } + + val oldValue = oldState.get() + val newValue = newState.get() + if (oldValue != newValue) { + listeners.forEach { it.get()?.invoke(newValue) } + } + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: DelegatingStateBase, + listenerCallback: (T) -> Unit, + val removeCallback: () -> Unit, + val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + override fun invoke() { + state.listeners.remove(this@ListenerEntry) + removeCallback() + ownerCallback.get()?.invoke() + } + } +} + +/** Default implementation of [DelegatingState] */ +private class DelegatingStateImpl(delegate: State) : + DelegatingStateBase>(delegate), DelegatingState + +/** Default implementation of [DelegatingMutableState] */ +private class DelegatingMutableStateImpl(delegate: MutableState) : + DelegatingStateBase>(delegate), DelegatingMutableState { + override fun set(mapper: (T) -> T) { + delegate.set(mapper) + } +} + +/** A [BasicState] which additionally implements [ReferenceHolder] */ +private class ReferenceHoldingBasicState(value: T) : BasicState(value), ReferenceHolder { + private val heldReferences = mutableListOf() + + override fun holdOnto(listener: Any): () -> Unit { + heldReferences.add(listener) + return { heldReferences.remove(listener) } + } +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/minimal/impl.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/minimal/impl.kt new file mode 100644 index 0000000..d8874e9 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/impl/minimal/impl.kt @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2.impl.minimal + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.gui.elementa.state.v2.MutableState +import gg.essential.gui.elementa.state.v2.Observer +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * Minimal mark-then-pull-based node graph implementation. + * + * This implementation operates in two simple phases: + * - The first phase pushes the may-be-dirty state through the graph to all potentially affected nodes + * - The second phase goes through all potentially affected effect nodes and recursively checks if they need to be + * updated. + * + * This does make for a fully correct reference implementation. + * However it always needs to visit all potentially affected effects on every update. + * In particular if we have a large mutable state (like a list) which is then split off into many smaller ones (like its + * items), all effects attached to all of the smaller nodes need to be visited, even if only a single item was modified + * in the list. + * + * For a more performant algorithm, see [gg.essential.gui.elementa.state.v2.impl.markpushpull.MarkThenPushAndPullImpl]. + */ +internal object MarkThenPullImpl : Impl { + override fun mutableState(value: T): MutableState { + val node = Node(NodeKind.Mutable, NodeState.Clean, UNREACHABLE, value) + return object : State by node, MutableState { + override fun set(mapper: (T) -> T) { + node.set(mapper(node.getUntracked())) + } + } + } + + override fun memo(func: Observer.() -> T): State = + Node(NodeKind.Memo, NodeState.Dirty, func, null) + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + val node = Node(NodeKind.Effect, NodeState.Dirty, func, Unit) + node.update(Update.get()) + val refCleanup = referenceHolder.holdOnto(node) + return { + node.cleanup() + refCleanup() + } + } + +} + +private enum class NodeKind { + /** + * A leaf node which represents a manually updated value which only changes when [Node.set] is invoked. + * It does not have any dependencies nor a [Node.func]. + */ + Mutable, + + /** + * An intermediate node which is lazily computed and lazily updated via [Node.func]. + * May have any number of both dependencies and dependents. + */ + Memo, + + /** + * A node which represents the root of a dependency tree. + * It does not have any dependents and does not produce any value. + * + * Unlike [Memo], it is not lazy and will be updated when any of its dependencies change. + * If any of its dependencies are lazy, they too will be updated as necessary for this node to obtain a complete + * view of up-to-date values. + */ + Effect, +} + +private enum class NodeState { + /** + * The [Node.value] is up-to-date. + * For [NodeKind.Effect], the [Node.func] has been run with the latest values. + */ + Clean, + + /** + * Some of the node's dependencies, including transitive one, may be [Dirty] and need to be checked. + */ + ToBeChecked, + + /** + * The [Node.value] is outdated and needs to be re-evaluated. + * For [NodeKind.Effect], the [Node.func] needs to be re-run. + */ + Dirty, + + /** + * The node has been disposed off and should no longer be updated. + */ + Dead, +} + +private class Node( + val kind: NodeKind, + private var state: NodeState, + private val func: Observer.() -> T, + private var value: T?, +) : State, Observer { + private val observed = mutableSetOf>() + private val dependencies = mutableListOf>() + private val dependents: MutableList>> = mutableListOf() + + override fun Observer.get(): T { + return getTracked(this@get) + } + + fun getTracked(observer: Observer): T { + if (observer is Node<*>) { + observer.observed.add(this) + } + return getUntracked() + } + + override fun getUntracked(): T { + if (state != NodeState.Clean) { + update(Update.get()) + } + @Suppress("UNCHECKED_CAST") + return value as T + } + + fun set(newValue: T) { + assert(kind == NodeKind.Mutable) + + if (value == newValue) { + return + } + + value = newValue + + val update = Update.get() + for (dep in dependents.iter()) { + dep.markDirty(update) + } + update.flush() + } + + private fun mark(update: Update, newState: NodeState) { + val oldState = state + if (oldState.ordinal >= newState.ordinal) { + return + } + + if (kind == NodeKind.Effect && oldState == NodeState.Clean) { + update.queueNode(this) + } + + state = newState + } + + private fun markDirty(update: Update) { + mark(update, NodeState.Dirty) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + private fun markToBeChecked(update: Update) { + if (state != NodeState.Clean) return + + mark(update, NodeState.ToBeChecked) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + fun update(update: Update) { + if (state == NodeState.Clean) { + return + } + + if (state == NodeState.ToBeChecked) { + for (dep in dependencies) { + dep.update(update) + if (state == NodeState.Dirty) { + break + } + } + } + + if (state == NodeState.Dirty) { + val newValue = func(this) + + if (state == NodeState.Dead) { + return + } + + for (i in dependencies.indices.reversed()) { + val dep = dependencies[i] + if (dep !in observed) { + dependencies.removeAt(i) + dep.removeDependent(this) + } + } + for (dep in observed) { + if (dep !in dependencies) { + dependencies.add(dep) + dep.addDependent(this) + } + } + observed.clear() + + if (value != newValue) { + value = newValue + + for (dep in dependents.iter()) { + dep.mark(update, NodeState.Dirty) + } + } + } + + state = NodeState.Clean + } + + fun cleanup() { + for (dep in dependencies) { + dep.removeDependent(this) + } + dependencies.clear() + + state = NodeState.Dead + } + + private var referenceQueueField: ReferenceQueue>? = null + private val referenceQueue: ReferenceQueue> + get() = referenceQueueField ?: ReferenceQueue>().also { referenceQueueField = it } + + private fun addDependent(node: Node<*>) { + cleanupStaleReferences() + dependents.add(WeakReference(node, referenceQueue)) + } + + private fun removeDependent(node: Node<*>) { + val index = dependents.indexOfFirst { it.get() == node } + if (index >= 0) { + dependents.removeAt(index) + } + } + + private fun cleanupStaleReferences() { + val queue = referenceQueueField ?: return + + if (queue.poll() == null) { + return + } + + @Suppress("ControlFlowWithEmptyBody") + while (queue.poll() != null); + + dependents.removeIf { it.get() == null } + } + + private fun MutableList>>.iter(): Iterator> { + return asSequence().mapNotNull { it.get() }.iterator() + } +} + +private class Update { + private var queue: MutableList> = mutableListOf() + private var processing: Boolean = false + + fun queueNode(node: Node<*>) { + queue.add(node) + } + + fun flush() { + if (processing || queue.isEmpty()) { + return + } + + processing = true + try { + var i = 0 + while (true) { + val node = queue.getOrNull(i) ?: break + node.update(this) + i++ + } + queue.clear() + } finally { + processing = false + } + } + + companion object { + private val INSTANCE = ThreadLocal.withInitial { Update() } + fun get(): Update = INSTANCE.get() + } +} + +private val UNREACHABLE: Observer.() -> Nothing = { error("unreachable") } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/list.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/list.kt new file mode 100644 index 0000000..9ed65e1 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/list.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +import gg.essential.gui.elementa.state.v2.collections.MutableTrackedList +import gg.essential.gui.elementa.state.v2.collections.TrackedList + +typealias ListState = State> +typealias MutableListState = MutableState> + +fun State>.toListState(): ListState { + var oldList = MutableTrackedList() + return memo { + val newList = get() + oldList.applyChanges(TrackedList.Change.estimate(oldList, newList)).also { oldList = it } + } +} + +fun ListState.mapChanges(init: (TrackedList) -> U, update: (old: U, changes: Sequence>) -> U): State { + var trackedList: TrackedList? = null + var trackedValue: U? = null + return memo { + val newList = get() + val oldList = trackedList + val newValue = + if (oldList == null) { + init(newList) + } else { + @Suppress("UNCHECKED_CAST") + update(trackedValue as U, newList.getChangesSince(oldList)) + } + + trackedList = newList + trackedValue = newValue + + newValue + } +} + +fun ListState.mapChange(init: (TrackedList) -> U, update: (old: U, change: TrackedList.Change) -> U): State = + mapChanges(init) { old, changes -> changes.fold(old, update) } + +fun listStateOf(vararg elements: T): ListState = + stateOf(MutableTrackedList(mutableListOf(*elements))) + +fun mutableListStateOf(vararg elements: T): MutableListState = + mutableStateOf(MutableTrackedList(mutableListOf(*elements))) + +fun MutableListState.set(index: Int, element: T) = set { it.set(index, element) } +fun MutableListState.setAll(newList: List) = set { it.applyChanges(TrackedList.Change.estimate(it, newList)) } +fun MutableListState.add(element: T) = set { it.add(element) } +fun MutableListState.add(index: Int, element: T) = set { it.add(index, element) } +fun MutableListState.addAll(elements: List) = set { it.addAll(elements) } +fun MutableListState.remove(element: T) = set { it.remove(element) } +fun MutableListState.removeAt(index: Int) = set { it.removeAt(index) } +fun MutableListState.removeAll(predicate: (T) -> Boolean) = set { it.removeAll(it.filter(predicate)) } +fun MutableListState.clear() = set { it.clear() } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/listCombinators.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/listCombinators.kt new file mode 100644 index 0000000..6f892f0 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/listCombinators.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +import gg.essential.gui.elementa.state.v2.collections.MutableTrackedList +import gg.essential.gui.elementa.state.v2.collections.MutableTrackedSet +import gg.essential.gui.elementa.state.v2.collections.TrackedList +import gg.essential.gui.elementa.state.v2.combinators.map +import gg.essential.gui.elementa.state.v2.combinators.zip + +fun ListState.toSet(): SetState { + val count = mutableMapOf() + return mapChange({ list -> + for (element in list) { + count.compute(element) { _, c -> (c ?: 0) + 1 } + } + MutableTrackedSet(list.toMutableSet()) + }, { set, change -> + when (change) { + is TrackedList.Add -> { + if (count.compute(change.element.value) { _, c -> (c ?: 0) + 1 } == 1) { + set.add(change.element.value) + } else { + set + } + } + is TrackedList.Remove -> { + if (count.compute(change.element.value) { _, c -> (c!! - 1).takeUnless { it == 0 } } == null) { + set.remove(change.element.value) + } else { + set + } + } + is TrackedList.Clear -> set.clear() + } + }) +} + +// mapList { it.filter(filter) } +fun ListState.filter(filter: (T) -> Boolean): ListState { + val indices = mutableListOf() + return mapChange({ list -> + MutableTrackedList(mutableListOf().also { filteredList -> + for (elem in list) { + if (filter(elem)) { + indices.add(filteredList.size) + filteredList.add(elem) + } else { + indices.add(-1) + } + } + }) + }) { list, change -> + when (change) { + is TrackedList.Add -> { + if (filter(change.element.value)) { + val mappedIndex = if (change.element.index == indices.size) { + // Fast path, add to end + list.size + } else { + // Slow path, to find the index of the newly added element, we need to find the index + // of the previous (non-filtered) element + var mappedIndex = 0 + for (i in (0 until change.element.index).reversed()) { + val index = indices[i] + if (index != -1) { + mappedIndex = index + 1 + break + } + } + // And then also increment the index of all elements that are after it + for (i in change.element.index .. indices.lastIndex) { + val index = indices[i] + if (index != -1) { + indices[i] = index + 1 + } + } + mappedIndex + } + indices.add(change.element.index, mappedIndex) + list.add(mappedIndex, change.element.value) + } else { + indices.add(change.element.index, -1) + list + } + } + is TrackedList.Remove -> { + val mappedIndex = indices.removeAt(change.element.index) + if (mappedIndex != -1) { + for (i in change.element.index .. indices.lastIndex) { + val index = indices[i] + if (index != -1) { + indices[i] = index - 1 + } + } + list.removeAt(mappedIndex) + } else { + list + } + } + is TrackedList.Clear -> { + indices.clear() + list.clear() + } + } + } +} + +// mapList { it.map(mapper) } +fun ListState.mapEach(mapper: (T) -> U): ListState = + mapChange({ MutableTrackedList(it.mapTo(mutableListOf(), mapper)) }) { list, change -> + when (change) { + is TrackedList.Add -> list.add(change.element.index, mapper(change.element.value)) + is TrackedList.Remove -> list.removeAt(change.element.index) + is TrackedList.Clear -> list.clear() + } + } + + +// TODO: all of these are based on mapList and as such are quite inefficient, might make sense to implement some as efficient primitives instead + +fun ListState.mapList(mapper: (List) -> List): ListState = + map(mapper).toListState() + +fun ListState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = + zip(otherState) { list, other -> list.map { transform(it, other) } }.toListState() + +fun ListState.zipElements(otherList: ListState, transform: (T, U) -> V) = + zip(otherList) { a, b -> a.zip(b, transform) }.toListState() + +fun ListState.mapEachNotNull(mapper: (T) -> U?) = mapList { it.mapNotNull(mapper) } + +fun ListState.filterNotNull() = mapList { it.filterNotNull() } + +inline fun ListState<*>.filterIsInstance(): ListState = map { it.filterIsInstance() }.toListState() + +fun ListState.flatMap(block: (T) -> Iterable) = mapList { it.flatMap(block) } + +fun ListState.isEmpty() = map { it.isEmpty() } + +fun ListState.isNotEmpty() = map { it.isNotEmpty() } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/set.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/set.kt new file mode 100644 index 0000000..9202314 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/set.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +import gg.essential.gui.elementa.state.v2.collections.* + +typealias SetState = State> +typealias MutableSetState = MutableState> + +fun State>.toSetState(): SetState { + var oldSet = MutableTrackedSet() + return memo { + val newSet = get() + oldSet.applyChanges(TrackedSet.Change.estimate(oldSet, newSet)).also { oldSet = it } + } +} + +fun SetState.mapChanges(init: (TrackedSet) -> U, update: (old: U, changes: Sequence>) -> U): State { + var trackedSet: TrackedSet? = null + var trackedValue: U? = null + return memo { + val newSet = get() + val oldSet = trackedSet + val newValue = + if (oldSet == null) { + init(newSet) + } else { + @Suppress("UNCHECKED_CAST") + update(trackedValue as U, newSet.getChangesSince(oldSet)) + } + + trackedSet = newSet + trackedValue = newValue + + newValue + } +} + +fun SetState.mapChange(init: (TrackedSet) -> U, update: (old: U, change: TrackedSet.Change) -> U): State = + mapChanges(init) { old, changes -> changes.fold(old, update) } + +fun mutableSetState(vararg elements: T): MutableSetState = + mutableStateOf(MutableTrackedSet(mutableSetOf(*elements))) + +fun MutableSetState.add(element: T) = set { it.add(element) } +fun MutableSetState.addAll(toAdd: Collection) = set { it.addAll(toAdd) } +fun MutableSetState.setAll(newSet: Set) = set { it.applyChanges(TrackedSet.Change.estimate(it, newSet)) } +fun MutableSetState.remove(element: T) = set { it.remove(element) } +fun MutableSetState.clear() = set { it.clear() } diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/setCombinators.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/setCombinators.kt new file mode 100644 index 0000000..e283fbc --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/setCombinators.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +import gg.essential.gui.elementa.state.v2.collections.MutableTrackedList +import gg.essential.gui.elementa.state.v2.collections.MutableTrackedSet +import gg.essential.gui.elementa.state.v2.collections.TrackedSet +import gg.essential.gui.elementa.state.v2.combinators.map +import gg.essential.gui.elementa.state.v2.combinators.zip + +fun SetState.toList(): ListState { + return mapChange({ MutableTrackedList(it.toMutableList()) }, { list, change -> + when (change) { + is TrackedSet.Add -> list.add(change.element) + is TrackedSet.Remove -> list.remove(change.element) + is TrackedSet.Clear -> list.clear() + } + }) +} + +fun SetState.filter(filter: (T) -> Boolean): SetState = + mapChange({ MutableTrackedSet(it.filterTo(mutableSetOf(), filter)) }) { set, change -> + when (change) { + is TrackedSet.Add -> { + if (filter(change.element)) { + set.add(change.element) + } else { + set + } + } + is TrackedSet.Remove -> set.remove(change.element) + is TrackedSet.Clear -> set.clear() + } + } + +fun SetState.mapEach(mapper: (T) -> U): SetState { + val mappedValues = mutableMapOf() + val mappedCount = mutableMapOf() + return mapChange({ set -> + MutableTrackedSet(set.mapTo(mutableSetOf()) { value -> + mapper(value).also { mappedValue -> + mappedValues[value] = mappedValue + mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1} + } + }) + }) { list, change -> + when (change) { + is TrackedSet.Add -> { + val mappedValue = mapper(change.element) + mappedValues[change.element] = mappedValue + mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1 } + list.add(mappedValue) + } + is TrackedSet.Remove -> { + val mappedValue = mappedValues.remove(change.element)!! + if (mappedCount.computeIfPresent(mappedValue) { _, i -> (i - 1).takeIf { i > 0 } } == null) { + list.remove(mappedValue) + } else { + list + } + } + is TrackedSet.Clear -> { + mappedValues.clear() + mappedCount.clear() + list.clear() + } + } + } +} + +// TODO: all of these are based on mapSet and as such are quite inefficient, might make sense to implement some as efficient primitives instead + +fun SetState.mapSet(mapper: (Set) -> Set): SetState = + map(mapper).toSetState() + +fun SetState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = + zip(otherState) { set, other -> set.mapTo(mutableSetOf()) { transform(it, other) } }.toSetState() + +fun SetState.mapEachNotNull(mapper: (T) -> U?) = mapSet { it.mapNotNullTo(mutableSetOf(), mapper) } + +fun SetState.filterNotNull() = mapSet { it.filterNotNullTo(mutableSetOf()) } + +inline fun SetState<*>.filterIsInstance(): SetState = map { it.filterIsInstanceTo>(mutableSetOf()) }.toSetState() diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/state.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/state.kt new file mode 100644 index 0000000..0f20970 --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/state.kt @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.gui.elementa.state.v2.impl.Impl +import gg.essential.gui.elementa.state.v2.impl.basic.MarkThenPushAndPullImpl + +private val impl: Impl = MarkThenPushAndPullImpl + +/** + * A marker interface for an object which may observe which states are being accessed, such that it can then subscribe + * to these states to be updated when they change. + * + * Note that the duration during which a given [Observer] can be used is usually limited to the call in which it was + * received. + * It should not be stored (neither in a field, nor implicitly in an asynchronous lambda) and then used at a later time. + * + * Note: This interface must not be be implemented by user code. The State implementation may cast it to its internal + * implementation type without checking. + */ +interface Observer { + /** + * Get the current value of the State object and subscribe the observer to be re-evaluated when it changes. + */ + operator fun State.invoke(): T = with(this@Observer) { get() } +} + +/** + * An [Observer] which does not track accesses. + * + * May be used to evaluate a method which requires an [Observer] once to get the current value when you do not care + * about future changes. + * To get the current value of a [State], one can also use the [State.getUntracked] shortcut. + */ +object Untracked : Observer + +/** + * Creates a [State] which lazily computes its value via the given pure function [func] and caches the result until + * one of the observed dependencies changes. + * + * You **MUST NOT** use [memo] when [func] triggers any side effects; there are no guarantees for when or even how often + * [func] is called (it is however guaranteed to always see a consistent view of all other [State]s). + * To have an external system react to changes in the State system (i.e. for it to "have an effect"), use [effect]. + * + * The two main use cases for [memo] are: + * - [func] represents a non-trivial / expensive computation which you do not want to re-evaluate on each access + * - [func] simplifies its dependencies (e.g. picks one item out of a list) and you do not want its dependents to + * unnecessarily be re-evaluated even though the simplified value is unchanged (e.g. whenever any other entry in the + * list is changed) + * + * If neither of the above applies, consider simply creating a custom [State] implementation which computes your [func] + * every time its [State.get] is called (e.g. instead of `memo { myState() + 1 }` write `State { myState() + 1 }`). + * Doing so has significantly lower overhead (just the cost of a single lambda) than [memo]. + */ +fun memo(func: Observer.() -> T): State = impl.memo(func) + +/** + * Creates a [State] which lazily [get][State.get]s its value from `this` State and caches the result until one of the + * observed dependencies changes. + * May return `this` if it is already such a State. + * + * Semantically `State { func() }.memo()` is equivalent to `memo { func() }`. + * + * @see [memo] + */ +fun State.memo(): State = memo inner@{ this@memo() } + +/** + * Runs the given function [func] once immediately and whenever any of the [State]s it [observes][Observer] change. + * + * A "cleanup" function is returned which when invoked will unregister the effect, such that it will no longer be called + * thereafter. + * + * Hint: If a [State] you wish to use often has unrelated changes you do not care about, consider breaking it down into + * a smaller [State] ahead of time using [memo]. + * + * ### Lifetime + * + * The effect registration is weak by default. + * This means that it may be garbage collected if no other strong references to the returned function exist. + * Once an effect is garbage collected, it will (obviously) no longer be called. + * + * Keeping a strong reference to the returned function is easy to forget, so this method requires you + * to explicitly pass in an object which will maintain a strong reference to it for you. + * With that, your effect will stay active **at least** as long as the given [owner] is alive (unless the returned + * function is explicitly invoked, in which case it ceases operation immediately). + * + * In general, the lifetime of your effect should match the lifetime of the passed [owner], usually the thing + * (e.g. [UIComponent]) the effect is modifying. + * If the owner far outlives your effect, you may be unnecessarily running your effect and leaking memory because owner + * will keep all those effects and anything they reference alive far beyond the point where they are needed. + * If your effect outlives the owner, then it may become inactive sooner than you expected and whatever it is + * updating might no longer update properly. + * + * If you wish to manually keep your effect alive (by holding on to the returned function), pass [ReferenceHolder.Weak] + * as the owner. + * + * ### Recursion + * + * You should avoid calling [MutableState.set] from the given function. + * + * While the State system does support recursion, such nested state changes cannot be performed atomically and as such + * it is very much possible that another [effect] has already observed both the value that trigger your [effect] but + * also the old value of the state you want to update; + * it will then be invoked again which, depending on what it does, may have unintended consequences. + * + * To have the value of [State] depend on one or more other [State]s, use [memo] to create it. + */ +fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit = impl.effect(referenceHolder, func) + +/** + * Runs the given function [func] whenever the value of `this` State changes. + * + * See [effect] for details. + */ +fun State.onChange(referenceHolder: ReferenceHolder, func: Observer.(value: T) -> Unit): () -> Unit { + var first = true + return effect(referenceHolder) { + val value = this@onChange() + if (first) { + first = false + } else { + func(value) + } + } +} + +/** + * The base for all Elementa State objects. + * + * State objects are essentially just a wrapper around a (potentially computed) value with the ability to subscribe to + * changes. + * + * The primary advantage of using state is that a single state object can be shared between multiple + * components or constraints as well as re-used and combined to derive other State from it. + * All in a declarative way, i.e. no need to manually go and remember to update every piece of GUI, you only update + * the base [MutableState] instance, and everyone who cares will have subscribed (directly or indirectly) and + * automatically be updated accordingly. + * + * This allows one value update to be seen by multiple components or constraints. + * For example, if a component has many text children, and they all share the same + * color state variable, then whenever the value of the state object is updated, all of the text + * components will instantly change color. + * + * State also composes well, e.g. a function which returns a `State` for whether a component is hovered can + * easily be mapped to one or more `State` (potentially taking into account other state too) which can then be + * used to color the background/outline/etc. of the same or other components. + * + * The most important primitives of the State system: + * - To create a simple [MutableState] which can be updated manually, use [mutableStateOf]. + * - To create [State] which derives its value from other [State], use [memo] or a custom [State] implementation (see + * the documentation on the former for details). + * - To make external systems react to [State] changes, use [effect]. + * + * The Elementa State system also provides a bunch of more subtle functionality that may not be apparent at first + * glance. E.g. it will allow state and effect nodes to be be garbage collected when they are no longer needed, and it + * will generally guarantee that all views of the State system are consistent, i.e. when there are states derived from + * other states, you'll either see the old value of all of them, or the updated values for all of them, but never an + * inconsistent mix of the two. + * + * Those readers familiar with other reactive/signal libraries (e.g. SolidJS, Leptos, Angular, MobX) may notice + * many similarities to these because [State] is pretty much Elementa's solution to the same set of problems. + */ +fun interface State { + /** + * Get the current value of this State object and subscribe the observer to be re-evaluated when it changes. + */ + fun Observer.get(): T + + /** + * Get the current value of this State object. + */ + fun getUntracked(): T = with(Untracked) { get() } + + /** Get the value of this State object */ + @Deprecated("Calls to this method are not tracked. If this is intentional, use `getUntracked` instead.") + fun get(): T = getUntracked() + + /** + * Register a listener which will be called whenever the value of this State object changes + * + * The listener registration is weak by default. This means that no strong reference to the + * listener is kept in this State object and your listener may be garbage collected if no other + * strong references to it exist. Once a listener is garbage collected, it will (obviously) no + * longer receive updates. + * + * Keeping a strong reference to your own listener is easy to forget, so this method requires you + * to explicitly pass in an object which will maintain a strong reference to your listener for + * you. With that, your listener will stay active **at least** as long as the given [owner] is + * alive (unless the returned callback in invoked). + * + * In general, the lifetime of your listener should match the lifetime of the passed [owner], + * usually the thing (e.g. [UIComponent]) the listener is modifying. If the owner far outlives + * your listener, you may be leaking memory because the owner will keep all those listeners and + * anything they reference alive far beyond the point where they are needed. If your listener + * outlives the owner, then it may become inactive sooner than you expected and whatever it is + * updating might no longer update properly. + * + * If you wish to manually keep your listener alive, pass [ReferenceHolder.Weak] as the owner. + * + * @return A callback which, when invoked, removes this listener + */ + @Deprecated("If this method is used to update dependent states, use `stateBy` instead.\n" + + "Otherwise the State system cannot be guaranteed that downsteam states have a consistent view of upstream" + + "values (i.e. so called \"glitches\" may occur) and all dependences will be forced to evaluate eagerly" + + "instead of the usual lazy behavior (where states are only updated if there is a consumer).\n" + + "\n" + + "If this method is used to drive a final effect (e.g. updating some non-State UI property), and you also" + + "care about the initial value of the state, consider using `effect` instead.\n" + + "If you really only care about changes and not the inital value, use `onChange`.") + fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = onChange(owner) { listener(it) } +} + +/* ReferenceHolder is defined in Elementa as: +/** + * Holds strong references to listeners to prevent them from being garbage collected. + * @see State.onSetValue + */ +interface ReferenceHolder { + fun holdOnto(listener: Any): () -> Unit + + object Weak : ReferenceHolder { + override fun holdOnto(listener: Any): () -> Unit = {} + } +} + */ + +/** A [State] with a value that can be changed via [set] */ +@JvmDefaultWithoutCompatibility +interface MutableState : State { + /** + * Update the value of this State object. + * + * After the value has been updated, all listeners of this State object are notified. + * + * The provided lambda must be a pure function which will return the new value for this State give + * the current value. + * + * Note that while most basic State implementations will call the lambda and notify listeners + * immediately, there is no general requirement for them to do so, and specialized State + * implementations may delay either or both to e.g. batch multiple updates together. + */ + fun set(mapper: (T) -> T) + + /** + * Update the value of this State object. + * + * After the value has been updated, all listeners of this State object are notified. + * + * Note that while most basic State implementations will update and notify listeners immediately, + * there is no general requirement for them to do so, and specialized State implementations may + * delay either or both to e.g. batch multiple updates together. + * + * @see [set] + */ + fun set(value: T) = set { value } +} + +/** A [State] delegating to a configurable target [State] */ +interface DelegatingState : State { + fun rebind(newState: State) +} + +/** A [MutableState] delegating to a configurable target [MutableState] */ +@JvmDefaultWithoutCompatibility +interface DelegatingMutableState : MutableState { + fun rebind(newState: MutableState) +} + +/** Creates a new [State] with the given value. */ +fun stateOf(value: T): State = ImmutableState(value) + +/** Creates a new [MutableState] with the given initial value. */ +fun mutableStateOf(value: T): MutableState = impl.mutableState(value) + +/** Creates a new [DelegatingState] with the given target [State]. */ +fun stateDelegatingTo(state: State): DelegatingState = impl.stateDelegatingTo(state) + +/** Creates a new [DelegatingMutableState] with the given target [MutableState]. */ +fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = + impl.mutableStateDelegatingTo(state) + +/** Creates a [State] which derives its value in a user-defined way from one or more other states */ +@Deprecated("See `State.onSetValue`. Use `stateBy` instead.") +fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, +): State = impl.derivedState(initialValue, builder) + +/** A simple, immutable implementation of [State] */ +private class ImmutableState(private val value: T) : State { + override fun get(): T = value + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = {} + override fun Observer.get(): T = value + override fun getUntracked(): T = value +} + +/** A simple implementation of [ReferenceHolder] */ +class ReferenceHolderImpl : ReferenceHolder { + private val heldReferences = mutableListOf() + + override fun holdOnto(listener: Any): () -> Unit { + heldReferences.add(listener) + return { heldReferences.remove(listener) } + } +} diff --git a/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/stateBy.kt b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/stateBy.kt new file mode 100644 index 0000000..6cd518e --- /dev/null +++ b/elementa/statev2/src/main/kotlin/gg/essential/gui/elementa/state/v2/stateBy.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.state.v2 + +/** + * Creates a state that derives its value using the given [block]. The value of any state may be accessed within this + * block via [StateByScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any + * one of them changes. + */ +@Deprecated("Use `memo` (result is cached) or `State` lambda (result is not cached)") +fun stateBy(block: StateByScope.() -> T): State { + return memo { + val scope = object : StateByScope { + override fun State.invoke(): T { + return with(this@memo) { get() } + } + } + block(scope) + } +} + +@Deprecated("Superseded by `Observer`") +interface StateByScope { + operator fun State.invoke(): T +} diff --git a/feature-flags/build.gradle.kts b/feature-flags/build.gradle.kts new file mode 100644 index 0000000..57c24e3 --- /dev/null +++ b/feature-flags/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import gg.essential.gradle.util.KotlinVersion + +plugins { + kotlin("jvm") + id("java-library") +} + +kotlin.jvmToolchain(8) + +repositories { + mavenCentral() +} + +dependencies { + implementation(kotlin("stdlib-jdk8", KotlinVersion.minimal.stdlib)) + + compileOnly("org.apache.logging.log4j:log4j-api:2.0-beta9") +} diff --git a/feature-flags/src/main/java/gg/essential/config/AccessedViaReflection.java b/feature-flags/src/main/java/gg/essential/config/AccessedViaReflection.java new file mode 100644 index 0000000..918fd4b --- /dev/null +++ b/feature-flags/src/main/java/gg/essential/config/AccessedViaReflection.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.config; + +import kotlin.annotation.Repeatable; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marker for classes/members which are only accessed via reflection and as such may otherwise appear as unused to the + * feature-flags-processor. + *

+ * {@link #value()} denotes the class/method that is accessing the annotated element: + * {@code "SomeClass.someMethod"} or just {@code "SomeClass"} for the constructor / static initializer
+ * This value must not be fully qualified. The processor does not resolves references in code, it operates on the + * surface-level tokens only, so it does not know the fully qualified names either. + * It must however include the parent class for inner classes: {@code "Outer.Inner.method"}. + *

+ * Note that the referenced element need not necessarily be the actual caller. + * Annotating an element with this annotation is for the purposes of dead code elimination equivalent to inserting a + * regular class/field/method reference into the target referenced by {@link #value()} (regardless of whether such a + * change would actually compile). + * As such, it may sometimes be more convenient to intentionally reference another piece of code if that piece is more + * indicative of whether the annotated element is unused. + * E.g. Instead of referencing Lwjgl3Loader in NativeImageReaderImpl, we reference NativeImageReader because that is + * more narrow, and whenever it exists, so should the impl. + *

+ * Also note that it will insert a reference to the annotated element only. If it is contained within an otherwise + * unused class, that class must be annotated as well. + */ +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.SOURCE) +@Repeatable +public @interface AccessedViaReflection { + String value(); +} diff --git a/feature-flags/src/main/java/gg/essential/config/FeatureFlags.java b/feature-flags/src/main/java/gg/essential/config/FeatureFlags.java new file mode 100644 index 0000000..2989c8f --- /dev/null +++ b/feature-flags/src/main/java/gg/essential/config/FeatureFlags.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.config; + +import kotlin.Pair; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.HashMap; +import java.util.Map; + +/** + * Various feature flags which control the behavior of Essential.
+ *
+ * Before a public build is made, these will be propagated in the source code by feature-flags-processor and any + * dead code will be eliminated. + * Certain types of code (mixins, event handlers, command handler methods, etc.) are exempt from elimination because it + * cannot know whether they are actually required. These can be manually annotated with {@link HideIfDisabled} if their + * presence should be hidden in release builds.
+ * You may also apply {@link HideIfDisabled} to other targets to make sure they are actually eliminated if the + * feature is disabled. The CI build will fail if any {@link HideIfDisabled} annotations for disabled features remain. + *
+ * All feature flags may be overwritten at build time by setting corresponding environment variables (prefixed with + * {@code ESSENTIAL_FEATURE_}, e.g. {@code ESSENTIAL_FEATURE_NEW_STUFF}) or the corresponding property in the + * {@code features.properties} file (preferable because it can be committed to git and thereby allows for reproducible + * builds) to either {@code true}, {@code false}, or a number between 0 and 100.
+ * If set to a number, the feature will be enabled for the given percentage of users based on their UUID.
+ * Additionally, if a feature flag is not forced to either true or false, it may be toggled at runtime by setting the + * corresponding system property to {@code true} or {@code false} (e.g. {@code -Dessential.feature.new_stuff=true}).
+ */ +public class FeatureFlags { + + private static final Logger LOGGER = LogManager.getLogger("Essential Logger"); + + + // Add any features here that should be displayed in the FeaturesEnabledModal + public static final Map> abTestingFlags = new HashMap<>(); + + +} diff --git a/feature-flags/src/main/java/gg/essential/config/LoadsResources.java b/feature-flags/src/main/java/gg/essential/config/LoadsResources.java new file mode 100644 index 0000000..dbe56dd --- /dev/null +++ b/feature-flags/src/main/java/gg/essential/config/LoadsResources.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.config; + +import kotlin.annotation.Repeatable; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks the method as accessing certain resource files, preventing these resources from being removed by the + * feature-flags-processor if the marked method is called. + *

+ * {@link #value()} must be a regular expression that matches the paths of all files used by the method. The path is + * relative to {@code src/main/resources} and must begin with a leading {@code /}. + * It may additionally contain placeholders of the form {@code %name%}. For each invocation of the method, these will + * be replaced with the string literal passed in as the argument with the respective name (though keyword arguments are + * not currently supported). If the argument passed in is not a string constant, the invocation is ignored. + * E.g. + *

{@code
+ * @LoadsResources("/assets/mine/%name%.png")
+ * InputStream loadMyPng(String name) {
+ *     return getClass().getResourceAsStream("/assets/mine/" + name + ".png");
+ * }
+ *
+ * @LoadsResources("/assets/mine/icon/[0-9]+.png")
+ * InputStream loadRandomIcon(int seed) {
+ *     return getClass().getResourceAsStream("/assets/mine/icon" + randomUInt(seed) + ".png");
+ * }
+ *
+ * void main() {
+ *     loadMyPng("example");
+ *     loadMyPng(SOME_CONSTANT); // this invocation will be ignored, only string literals are supported
+ *     loadRandomIcon(randomInt()); // if the argument isn't used, it doesn't need to be a string constant; this works
+ * }
+ * }
+ */ +@Target({ElementType.CONSTRUCTOR, ElementType.METHOD}) +@Retention(RetentionPolicy.SOURCE) +@Repeatable +public @interface LoadsResources { + String value(); + + /** + * Adds LoadsResources behavior to certain builtin / third-party methods, taking advantage of the fact that the + * feature-flags-processor only care about the simple name, not the fully qualified name nor the type. + */ + @AccessedViaReflection("LoadsResources") + class LoadsResourcesBuiltins { + @AccessedViaReflection("LoadsResources") + @LoadsResources("%path%") + void getResource(String path) {} + @AccessedViaReflection("LoadsResources") + @LoadsResources("%path%") + void getResources(String path) {} + @AccessedViaReflection("LoadsResources") + @LoadsResources("%path%") + void getResourceAsStream(String path) {} + @AccessedViaReflection("LoadsResources") + @LoadsResources("/assets/%namespace%/%path%(\\.[a-z]+)?") + void Identifier(String namespace, String path) {} + @AccessedViaReflection("LoadsResources") + @LoadsResources("/assets/%namespace%/%path%(\\.[a-z]+)?") + void ResourceLocation(String namespace, String path) {} + } +} diff --git a/feature-flags/src/main/kotlin/gg/essential/config/HideIfDisabled.kt b/feature-flags/src/main/kotlin/gg/essential/config/HideIfDisabled.kt new file mode 100644 index 0000000..5bc4cf5 --- /dev/null +++ b/feature-flags/src/main/kotlin/gg/essential/config/HideIfDisabled.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.config + +/** + * Ensures that the annotated element is removed from the class file at build time if the given feature flag is + * disabled at build time. Fails the build if this is not possible. + * + * + * Note that this cannot (and MUST NOT) be used to control behavior because it unconditionally removes the element + * at build time only. It has no effect at runtime (it is in fact removed at build time) and the element will not be + * removed if the feature flag is set to any value other than `false` (e.g. A/B testing) at build time. + * The only purpose of this annotation is to hide code which should not yet be visible to the general public. + */ +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.EXPRESSION, // function/constructor call arguments only + AnnotationTarget.TYPEALIAS, +) +@Retention(AnnotationRetention.SOURCE) +@Repeatable +annotation class HideIfDisabled( + /** + * The name of a flag from [FeatureFlags]. + */ + val value: String +) diff --git a/features.properties b/features.properties new file mode 100644 index 0000000..652c3e5 --- /dev/null +++ b/features.properties @@ -0,0 +1,3 @@ +always=true +updated_gifting_modal=true +new_toasts=true diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5936582 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +essential.defaults.loom=0 +essential.defaults.loom.fabric-loader=net.fabricmc:fabric-loader:0.15.10 +kotlin.stdlib.default.dependency=false +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.configureondemand=true +org.gradle.parallel.threads=128 +org.gradle.jvmargs=-Xmx16G +minecraftVersion=11202 +version=1.3.2+g8cdc70f906 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..adb9ab1 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,5 @@ +[versions] +universalcraft = "337" +elementa = "638" +vigilance = "297" +mixinextras = "0.3.5" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 zcma&O1CT9Y(k9%tZQHhO+qUh#ZQHhO+qmuS+qP|E@9xZO?0h@l{(r>DQ>P;GjjD{w zH}lENr;dU&FbEU?00aa80D$0M0RRB{U*7-#kbjS|qAG&4l5%47zyJ#WrfA#1$1Ctx zf&Z_d{GW=lf^w2#qRJ|CvSJUi(^E3iv~=^Z(zH}F)3Z%V3`@+rNB7gTVU{Bb~90p|f+0(v;nz01EG7yDMX9@S~__vVgv%rS$+?IH+oZ03D5zYrv|^ zC1J)SruYHmCki$jLBlTaE5&dFG9-kq3!^i>^UQL`%gn6)jz54$WDmeYdsBE9;PqZ_ zoGd=P4+|(-u4U1dbAVQrFWoNgNd;0nrghPFbQrJctO>nwDdI`Q^i0XJDUYm|T|RWc zZ3^Qgo_Qk$%Fvjj-G}1NB#ZJqIkh;kX%V{THPqOyiq)d)0+(r9o(qKlSp*hmK#iIY zA^)Vr$-Hz<#SF=0@tL@;dCQsm`V9s1vYNq}K1B)!XSK?=I1)tX+bUV52$YQu*0%fnWEukW>mxkz+%3-S!oguE8u#MGzST8_Dy^#U?fA@S#K$S@9msUiX!gd_ow>08w5)nX{-KxqMOo7d?k2&?Vf z&diGDtZr(0cwPe9z9FAUSD9KC)7(n^lMWuayCfxzy8EZsns%OEblHFSzP=cL6}?J| z0U$H!4S_TVjj<`6dy^2j`V`)mC;cB%* z8{>_%E1^FH!*{>4a7*C1v>~1*@TMcLK{7nEQ!_igZC}ikJ$*<$yHy>7)oy79A~#xE zWavoJOIOC$5b6*q*F_qN1>2#MY)AXVyr$6x4b=$x^*aqF*L?vmj>Mgv+|ITnw_BoW zO?jwHvNy^prH{9$rrik1#fhyU^MpFqF2fYEt(;4`Q&XWOGDH8k6M=%@fics4ajI;st# zCU^r1CK&|jzUhRMv;+W~6N;u<;#DI6cCw-otsc@IsN3MoSD^O`eNflIoR~l4*&-%RBYk@gb^|-JXs&~KuSEmMxB}xSb z@K76cXD=Y|=I&SNC2E+>Zg?R6E%DGCH5J1nU!A|@eX9oS(WPaMm==k2s_ueCqdZw| z&hqHp)47`c{BgwgvY2{xz%OIkY1xDwkw!<0veB#yF4ZKJyabhyyVS`gZepcFIk%e2 zTcrmt2@-8`7i-@5Nz>oQWFuMC_KlroCl(PLSodswHqJ3fn<;gxg9=}~3x_L3P`9Sn zChIf}8vCHvTriz~T2~FamRi?rh?>3bX1j}%bLH+uFX+p&+^aXbOK7clZxdU~6Uxgy z8R=obwO4dL%pmVo*Ktf=lH6hnlz_5k3cG;m8lgaPp~?eD!Yn2kf)tU6PF{kLyn|oI@eQ`F z3IF7~Blqg8-uwUuWZScRKn%c2_}dXB6Dx_&xR*n9M9LXasJhtZdr$vBY!rP{c@=)& z#!?L$2UrkvClwQO>U*fSMs67oSj2mxiJ$t;E|>q%Kh_GzzWWO&3;ufU%2z%ucBU8H z3WIwr$n)cfCXR&>tyB7BcSInK>=ByZA%;cVEJhcg<#6N{aZC4>K41XF>ZgjG`z_u& zGY?;Ad?-sgiOnI`oppF1o1Gurqbi*;#x2>+SSV6|1^G@ooVy@fg?wyf@0Y!UZ4!}nGuLeC^l)6pwkh|oRY`s1Pm$>zZ3u-83T|9 zGaKJIV3_x+u1>cRibsaJpJqhcm%?0-L;2 zitBrdRxNmb0OO2J%Y&Ym(6*`_P3&&5Bw157{o7LFguvxC$4&zTy#U=W*l&(Q2MNO} zfaUwYm{XtILD$3864IA_nn34oVa_g^FRuHL5wdUd)+W-p-iWCKe8m_cMHk+=? zeKX)M?Dt(|{r5t7IenkAXo%&EXIb-i^w+0CX0D=xApC=|Xy(`xy+QG^UyFe z+#J6h_&T5i#sV)hj3D4WN%z;2+jJcZxcI3*CHXGmOF3^)JD5j&wfX)e?-|V0GPuA+ zQFot%aEqGNJJHn$!_}#PaAvQ^{3-Ye7b}rWwrUmX53(|~i0v{}G_sI9uDch_brX&6 zWl5Ndj-AYg(W9CGfQf<6!YmY>Ey)+uYd_JNXH=>|`OH-CDCmcH(0%iD_aLlNHKH z7bcW-^5+QV$jK?R*)wZ>r9t}loM@XN&M-Pw=F#xn(;u3!(3SXXY^@=aoj70;_=QE9 zGghsG3ekq#N||u{4We_25U=y#T*S{4I{++Ku)> zQ!DZW;pVcn>b;&g2;YE#+V`v*Bl&Y-i@X6D*OpNA{G@JAXho&aOk(_j^weW{#3X5Y z%$q_wpb07EYPdmyH(1^09i$ca{O<}7) zRWncXdSPgBE%BM#by!E>tdnc$8RwUJg1*x($6$}ae$e9Knj8gvVZe#bLi!<+&BkFj zg@nOpDneyc+hU9P-;jmOSMN|*H#>^Ez#?;%C3hg_65leSUm;iz)UkW)jX#p)e&S&M z1|a?wDzV5NVnlhRBCd_;F87wp>6c<&nkgvC+!@KGiIqWY4l}=&1w7|r6{oBN8xyzh zG$b#2=RJp_iq6)#t5%yLkKx(0@D=C3w+oiXtSuaQ%I1WIb-eiE$d~!)b@|4XLy!CZ z9p=t=%3ad@Ep+<9003D2KZ5VyP~_n$=;~r&YUg5UZ0KVD&tR1DHy9x)qWtKJp#Kq# zP*8p#W(8JJ_*h_3W}FlvRam?<4Z+-H77^$Lvi+#vmhL9J zJ<1SV45xi;SrO2f=-OB(7#iNA5)x1uNC-yNxUw|!00vcW2PufRm>e~toH;M0Q85MQLWd?3O{i8H+5VkR@l9Dg-ma ze2fZ%>G(u5(k9EHj2L6!;(KZ8%8|*-1V|B#EagbF(rc+5iL_5;Eu)L4Z-V;0HfK4d z*{utLse_rvHZeQ>V5H=f78M3Ntg1BPxFCVD{HbNA6?9*^YIq;B-DJd{Ca2L#)qWP? zvX^NhFmX?CTWw&Ns}lgs;r3i+Bq@y}Ul+U%pzOS0Fcv9~aB(0!>GT0)NO?p=25LjN z2bh>6RhgqD7bQj#k-KOm@JLgMa6>%-ok1WpOe)FS^XOU{c?d5shG(lIn3GiVBxmg`u%-j=)^v&pX1JecJics3&jvPI)mDut52? z3jEA)DM%}BYbxxKrizVYwq?(P&19EXlwD9^-6J+4!}9{ywR9Gk42jjAURAF&EO|~N z)?s>$Da@ikI4|^z0e{r`J8zIs>SpM~Vn^{3fArRu;?+43>lD+^XtUcY1HidJwnR6+ z!;oG2=B6Z_=M%*{z-RaHc(n|1RTKQdNjjV!Pn9lFt^4w|AeN06*j}ZyhqZ^!-=cyGP_ShV1rGxkx8t zB;8`h!S{LD%ot``700d0@Grql(DTt4Awgmi+Yr0@#jbe=2#UkK%rv=OLqF)9D7D1j z!~McAwMYkeaL$~kI~90)5vBhBzWYc3Cj1WI0RS`z000R8-@ET0dA~*r(gSiCJmQMN&4%1D zyVNf0?}sBH8zNbBLn>~(W{d3%@kL_eQ6jEcR{l>C|JK z(R-fA!z|TTRG40|zv}7E@PqCAXP3n`;%|SCQ|ZS%ym$I{`}t3KPL&^l5`3>yah4*6 zifO#{VNz3)?ZL$be;NEaAk9b#{tV?V7 zP|wf5YA*1;s<)9A4~l3BHzG&HH`1xNr#%){4xZ!jq%o=7nN*wMuXlFV{HaiQLJ`5G zBhDi#D(m`Q1pLh@Tq+L;OwuC52RdW7b8}~60WCOK5iYMUad9}7aWBuILb({5=z~YF zt?*Jr5NG+WadM{mDL>GyiByCuR)hd zA=HM?J6l1Xv0Dl+LW@w$OTcEoOda^nFCw*Sy^I@$sSuneMl{4ys)|RY#9&NxW4S)9 zq|%83IpslTLoz~&vTo!Ga@?rj_kw{|k{nv+w&Ku?fyk4Ki4I?);M|5Axm)t+BaE)D zm(`AQ#k^DWrjbuXoJf2{Aj^KT zFb1zMSqxq|vceV+Mf-)$oPflsO$@*A0n0Z!R{&(xh8s}=;t(lIy zv$S8x>m;vQNHuRzoaOo?eiWFe{0;$s`Bc+Osz~}Van${u;g(su`3lJ^TEfo~nERfP z)?aFzpDgnLYiERsKPu|0tq4l2wT)Atr6Qb%m-AUn6HnCue*yWICp7TjW$@sO zm5rm4aTcPQ(rfi7a`xP7cKCFrJD}*&_~xgLyr^-bmsL}y;A5P|al8J3WUoBSjqu%v zxC;mK!g(7r6RRJ852Z~feoC&sD3(6}^5-uLK8o)9{8L_%%rItZK9C){UxB|;G>JbP zsRRtS4-3B*5c+K2kvmgZK8472%l>3cntWUOVHxB|{Ay~aOg5RN;{PJgeVD*H%ac+y!h#wi%o2bF2Ca8IyMyH{>4#{E_8u^@+l-+n=V}Sq?$O z{091@v%Bd*3pk0^2UtiF9Z+(a@wy6 zUdw8J*ze$K#=$48IBi1U%;hmhO>lu!uU;+RS}p&6@rQila7WftH->*A4=5W|Fmtze z)7E}jh@cbmr9iup^i%*(uF%LG&!+Fyl@LFA-}Ca#bxRfDJAiR2dt6644TaYw1Ma79 zt8&DYj31j^5WPNf5P&{)J?WlCe@<3u^78wnd(Ja4^a>{^Tw}W>|Cjt^If|7l^l)^Q zbz|7~CF(k_9~n|h;ysZ+jHzkXf(*O*@5m zLzUmbHp=x!Q|!9NVXyipZ3)^GuIG$k;D)EK!a5=8MFLI_lpf`HPKl=-Ww%z8H_0$j ztJ||IfFG1lE9nmQ0+jPQy zCBdKkjArH@K7jVcMNz);Q(Q^R{d5G?-kk;Uu_IXSyWB)~KGIizZL(^&qF;|1PI7!E zTP`%l)gpX|OFn&)M%txpQ2F!hdA~hX1Cm5)IrdljqzRg!f{mN%G~H1&oqe`5eJCIF zHdD7O;AX-{XEV(a`gBFJ9ews#CVS2y!&>Cm_dm3C8*n3MA*e67(WC?uP@8TXuMroq z{#w$%z@CBIkRM7?}Xib+>hRjy?%G!fiw8! z8(gB+8J~KOU}yO7UGm&1g_MDJ$IXS!`+*b*QW2x)9>K~Y*E&bYMnjl6h!{17_8d!%&9D`a7r&LKZjC<&XOvTRaKJ1 zUY@hl5^R&kZl3lU3njk`3dPzxj$2foOL26r(9zsVF3n_F#v)s5vv3@dgs|lP#eylq62{<-vczqP!RpVBTgI>@O6&sU>W|do17+#OzQ7o5A$ICH z?GqwqnK^n2%LR;$^oZM;)+>$X3s2n}2jZ7CdWIW0lnGK-b#EG01)P@aU`pg}th&J-TrU`tIpb5t((0eu|!u zQz+3ZiOQ^?RxxK4;zs=l8q!-n7X{@jSwK(iqNFiRColuEOg}!7cyZi`iBX4g1pNBj zAPzL?P^Ljhn;1$r8?bc=#n|Ed7wB&oHcw()&*k#SS#h}jO?ZB246EGItsz*;^&tzp zu^YJ0=lwsi`eP_pU8}6JA7MS;9pfD;DsSsLo~ogzMNP70@@;Fm8f0^;>$Z>~}GWRw!W5J3tNX*^2+1f3hz{~rIzJo z6W%J(H!g-eI_J1>0juX$X4Cl6i+3wbc~k146UIX&G22}WE>0ga#WLsn9tY(&29zBvH1$`iWtTe zG2jYl@P!P)eb<5DsR72BdI7-zP&cZNI{7q3e@?N8IKc4DE#UVr->|-ryuJXk^u^>4 z$3wE~=q390;XuOQP~TNoDR?#|NSPJ%sTMInA6*rJ%go|=YjGe!B>z6u$IhgQSwoV* zjy3F2#I>uK{42{&IqP59)Y(1*Z>>#W8rCf4_eVsH)`v!P#^;BgzKDR`ARGEZzkNX+ zJUQu=*-ol=Xqqt5=`=pA@BIn@6a9G8C{c&`i^(i+BxQO9?YZ3iu%$$da&Kb?2kCCo zo7t$UpSFWqmydXf@l3bVJ=%K?SSw)|?srhJ-1ZdFu*5QhL$~-IQS!K1s@XzAtv6*Y zl8@(5BlWYLt1yAWy?rMD&bwze8bC3-GfNH=p zynNFCdxyX?K&G(ZZ)afguQ2|r;XoV^=^(;Cku#qYn4Lus`UeKt6rAlFo_rU`|Rq z&G?~iWMBio<78of-2X(ZYHx~=U0Vz4btyXkctMKdc9UM!vYr~B-(>)(Hc|D zMzkN4!PBg%tZoh+=Gba!0++d193gbMk2&krfDgcbx0jI92cq?FFESVg0D$>F+bil} zY~$)|>1HZsX=5sAZ2WgPB5P=8X#TI+NQ(M~GqyVB53c6IdX=k>Wu@A0Svf5#?uHaF zsYn|koIi3$(%GZ2+G+7Fv^lHTb#5b8sAHSTnL^qWZLM<(1|9|QFw9pnRU{svj}_Al zL)b9>fN{QiA($8peNEJyy`(a{&uh-T4_kdZFIVsKKVM(?05}76EEz?#W za^fiZOAd14IJ4zLX-n7Lq0qlQ^lW8Cvz4UKkV9~P}>sq0?xD3vg+$4vLm~C(+ zM{-3Z#qnZ09bJ>}j?6ry^h+@PfaD7*jZxBEY4)UG&daWb??6)TP+|3#Z&?GL?1i+280CFsE|vIXQbm| zM}Pk!U`U5NsNbyKzkrul-DzwB{X?n3E6?TUHr{M&+R*2%yOiXdW-_2Yd6?38M9Vy^ z*lE%gA{wwoSR~vN0=no}tP2Ul5Gk5M(Xq`$nw#ndFk`tcpd5A=Idue`XZ!FS>Q zG^0w#>P4pPG+*NC9gLP4x2m=cKP}YuS!l^?sHSFftZy{4CoQrb_ z^20(NnG`wAhMI=eq)SsIE~&Gp9Ne0nD4%Xiu|0Fj1UFk?6avDqjdXz{O1nKao*46y zT8~iA%Exu=G#{x=KD;_C&M+Zx4+n`sHT>^>=-1YM;H<72k>$py1?F3#T1*ef9mLZw z5naLQr?n7K;2l+{_uIw*_1nsTn~I|kkCgrn;|G~##hM;9l7Jy$yJfmk+&}W@JeKcF zx@@Woiz8qdi|D%aH3XTx5*wDlbs?dC1_nrFpm^QbG@wM=i2?Zg;$VK!c^Dp8<}BTI zyRhAq@#%2pGV49*Y5_mV4+OICP|%I(dQ7x=6Ob}>EjnB_-_18*xrY?b%-yEDT(wrO z9RY2QT0`_OpGfMObKHV;QLVnrK%mc?$WAdIT`kJQT^n%GuzE7|9@k3ci5fYOh(287 zuIbg!GB3xLg$YN=n)^pHGB0jH+_iIiC=nUcD;G6LuJsjn2VI1cyZx=a?ShCsF==QK z;q~*m&}L<-cb+mDDXzvvrRsybcgQ;Vg21P(uLv5I+eGc7o7tc6`;OA9{soHFOz zT~2?>Ts}gprIX$wRBb4yE>ot<8+*Bv`qbSDv*VtRi|cyWS>)Fjs>fkNOH-+PX&4(~ z&)T8Zam2L6puQl?;5zg9h<}k4#|yH9czHw;1jw-pwBM*O2hUR6yvHATrI%^mvs9q_ z&ccT0>f#eDG<^WG^q@oVqlJrhxH)dcq2cty@l3~|5#UDdExyXUmLQ}f4#;6fI{f^t zDCsgIJ~0`af%YR%Ma5VQq-p21k`vaBu6WE?66+5=XUd%Ay%D$irN>5LhluRWt7 zov-=f>QbMk*G##&DTQyou$s7UqjjW@k6=!I@!k+S{pP8R(2=e@io;N8E`EOB;OGoI zw6Q+{X1_I{OO0HPpBz!X!@`5YQ2)t{+!?M_iH25X(d~-Zx~cXnS9z>u?+If|iNJbx zyFU2d1!ITX64D|lE0Z{dLRqL1Ajj=CCMfC4lD3&mYR_R_VZ>_7_~|<^o*%_&jevU+ zQ4|qzci=0}Jydw|LXLCrOl1_P6Xf@c0$ieK2^7@A9UbF{@V_0p%lqW|L?5k>bVM8|p5v&2g;~r>B8uo<4N+`B zH{J)h;SYiIVx@#jI&p-v3dwL5QNV1oxPr8J%ooezTnLW>i*3Isb49%5i!&ac_dEXv zvXmVUck^QHmyrF8>CGXijC_R-y(Qr{3Zt~EmW)-nC!tiH`wlw5D*W7Pip;T?&j%kX z6DkZX4&}iw>hE(boLyjOoupf6JpvBG8}jIh!!VhnD0>}KSMMo{1#uU6kiFcA04~|7 zVO8eI&x1`g4CZ<2cYUI(n#wz2MtVFHx47yE5eL~8bot~>EHbevSt}LLMQX?odD{Ux zJMnam{d)W4da{l7&y-JrgiU~qY3$~}_F#G7|MxT)e;G{U`In&?`j<5D->}cb{}{T(4DF0BOk-=1195KB-E*o@c?`>y#4=dMtYtSY=&L{!TAjFVcq0y@AH`vH! z$41+u!Ld&}F^COPgL(EE{0X7LY&%D7-(?!kjFF7=qw<;`V{nwWBq<)1QiGJgUc^Vz ztMUlq1bZqKn17|6x6iAHbWc~l1HcmAxr%$Puv!znW)!JiukwIrqQ00|H$Z)OmGG@= zv%A8*4cq}(?qn4rN6o`$Y))(MyXr8R<2S^J+v(wmFmtac!%VOfN?&(8Nr!T@kV`N; z*Q33V3t`^rN&aBiHet)18wy{*wi1=W!B%B-Q6}SCrUl$~Hl{@!95ydml@FK8P=u4s z4e*7gV2s=YxEvskw2Ju!2%{8h01rx-3`NCPc(O zH&J0VH5etNB2KY6k4R@2Wvl^Ck$MoR3=)|SEclT2ccJ!RI9Nuter7u9@;sWf-%um;GfI!=eEIQ2l2p_YWUd{|6EG ze{yO6;lMc>;2tPrsNdi@&1K6(1;|$xe8vLgiouj%QD%gYk`4p{Ktv9|j+!OF-P?@p z;}SV|oIK)iwlBs+`ROXkhd&NK zzo__r!B>tOXpBJMDcv!Mq54P+n4(@dijL^EpO1wdg~q+!DT3lB<>9AANSe!T1XgC=J^)IP0XEZ()_vpu!!3HQyJhwh?r`Ae%Yr~b% zO*NY9t9#qWa@GCPYOF9aron7thfWT`eujS4`t2uG6)~JRTI;f(ZuoRQwjZjp5Pg34 z)rp$)Kr?R+KdJ;IO;pM{$6|2y=k_siqvp%)2||cHTe|b5Ht8&A{wazGNca zX$Ol?H)E_R@SDi~4{d-|8nGFhZPW;Cts1;08TwUvLLv&_2$O6Vt=M)X;g%HUr$&06 zISZb(6)Q3%?;3r~*3~USIg=HcJhFtHhIV(siOwV&QkQe#J%H9&E21!C*d@ln3E@J* zVqRO^<)V^ky-R|%{(9`l-(JXq9J)1r$`uQ8a}$vr9E^nNiI*thK8=&UZ0dsFN_eSl z(q~lnD?EymWLsNa3|1{CRPW60>DSkY9YQ;$4o3W7Ms&@&lv9eH!tk~N&dhqX&>K@} zi1g~GqglxkZ5pEFkllJ)Ta1I^c&Bt6#r(QLQ02yHTaJB~- zCcE=5tmi`UA>@P=1LBfBiqk)HB4t8D?02;9eXj~kVPwv?m{5&!&TFYhu>3=_ zsGmYZ^mo*-j69-42y&Jj0cBLLEulNRZ9vXE)8~mt9C#;tZs;=#M=1*hebkS;7(aGf zcs7zH(I8Eui9UU4L--))yy`&d&$In&VA2?DAEss4LAPCLd>-$i?lpXvn!gu^JJ$(DoUlc6wE98VLZ*z`QGQov5l4Fm_h?V-;mHLYDVOwKz7>e4+%AzeO>P6v}ndPW| zM>m#6Tnp7K?0mbK=>gV}=@k*0Mr_PVAgGMu$j+pWxzq4MAa&jpCDU&-5eH27Iz>m^ zax1?*HhG%pJ((tkR(V(O(L%7v7L%!_X->IjS3H5kuXQT2!ow(;%FDE>16&3r){!ex zhf==oJ!}YU89C9@mfDq!P3S4yx$aGB?rbtVH?sHpg?J5C->!_FHM%Hl3#D4eplxzQ zRA+<@LD%LKSkTk2NyWCg7u=$%F#;SIL44~S_OGR}JqX}X+=bc@swpiClB`Zbz|f!4 z7Ysah7OkR8liXfI`}IIwtEoL}(URrGe;IM8%{>b1SsqXh)~w}P>yiFRaE>}rEnNkT z!HXZUtxUp1NmFm)Dm@-{FI^aRQqpSkz}ZSyKR%Y}YHNzBk)ZIp} zMtS=aMvkgWKm9&oTcU0?S|L~CDqA+sHpOxwnswF-fEG)cXCzUR?ps@tZa$=O)=L+5 zf%m58cq8g_o}3?Bhh+c!w4(7AjxwQ3>WnVi<{{38g7yFboo>q|+7qs<$8CPXUFAN< zG&}BHbbyQ5n|qqSr?U~GY{@GJ{(Jny{bMaOG{|IkUj7tj^9pa9|FB_<+KHLxSxR;@ zHpS$4V)PP+tx}22fWx(Ku9y+}Ap;VZqD0AZW4gCDTPCG=zgJmF{|x;(rvdM|2|9a}cex6xrMkERnkE;}jvU-kmzd%_J50$M`lIPCKf+^*zL=@LW`1SaEc%=m zQ+lT06Gw+wVwvQ9fZ~#qd430v2HndFsBa9WjD0P}K(rZYdAt^5WQIvb%D^Q|pkVE^ zte$&#~zmULFACGfS#g=2OLOnIf2Of-k!(BIHjs77nr!5Q1*I9 z1%?=~#Oss!rV~?-6Gm~BWJiA4mJ5TY&iPm_$)H1_rTltuU1F3I(qTQ^U$S>%$l z)Wx1}R?ij0idp@8w-p!Oz{&*W;v*IA;JFHA9%nUvVDy7Q8woheC#|8QuDZb-L_5@R zOqHwrh|mVL9b=+$nJxM`3eE{O$sCt$UK^2@L$R(r^-_+z?lOo+me-VW=Zw z-Bn>$4ovfWd%SPY`ab-u9{INc*k2h+yH%toDHIyqQ zO68=u`N}RIIs7lsn1D){)~%>ByF<>i@qFb<-axvu(Z+6t7v<^z&gm9McRB~BIaDn$ z#xSGT!rzgad8o>~kyj#h1?7g96tOcCJniQ+*#=b7wPio>|6a1Z?_(TS{)KrPe}(8j z!#&A=k(&Pj^F;r)CI=Z{LVu>uj!_W1q4b`N1}E(i%;BWjbEcnD=mv$FL$l?zS6bW!{$7j1GR5ocn94P2u{ z70tAAcpqtQo<@cXw~@i-@6B23;317|l~S>CB?hR5qJ%J3EFgyBdJd^fHZu7AzHF(BQ!tyAz^L0`X z23S4Fe{2X$W0$zu9gm%rg~A>ijaE#GlYlrF9$ds^QtaszE#4M(OLVP2O-;XdT(XIC zatwzF*)1c+t~c{L=fMG8Z=k5lv>U0;C{caN1NItnuSMp)6G3mbahu>E#sj&oy94KC zpH}8oEw{G@N3pvHhp{^-YaZeH;K+T_1AUv;IKD<=mv^&Ueegrb!yf`4VlRl$M?wsl zZyFol(2|_QM`e_2lYSABpKR{{NlxlDSYQNkS;J66aT#MSiTx~;tUmvs-b*CrR4w=f z8+0;*th6kfZ3|5!Icx3RV11sp=?`0Jy3Fs0N4GZQMN=8HmT6%x9@{Dza)k}UwL6JT zHRDh;%!XwXr6yuuy`4;Xsn0zlR$k%r%9abS1;_v?`HX_hI|+EibVnlyE@3aL5vhQq zlIG?tN^w@0(v9M*&L+{_+RQZw=o|&BRPGB>e5=ys7H`nc8nx)|-g;s7mRc7hg{GJC zAe^vCIJhajmm7C6g! zL&!WAQ~5d_5)00?w_*|*H>3$loHrvFbitw#WvLB!JASO?#5Ig5$Ys10n>e4|3d;tS zELJ0|R4n3Az(Fl3-r^QiV_C;)lQ1_CW{5bKS15U|E9?ZgLec@%kXr84>5jV2a5v=w z?pB1GPdxD$IQL4)G||B_lI+A=08MUFFR4MxfGOu07vfIm+j=z9tp~5i_6jb`tR>qV z$#`=BQ*jpCjm$F0+F)L%xRlnS%#&gro6PiRfu^l!EVan|r3y}AHJQOORGx4~ z&<)3=K-tx518DZyp%|!EqpU!+X3Et7n2AaC5(AtrkW>_57i}$eqs$rupubg0a1+WO zGHZKLN2L0D;ab%{_S1Plm|hx8R?O14*w*f&2&bB050n!R2by zw!@XOQx$SqZ5I<(Qu$V6g>o#A!JVwErWv#(Pjx=KeS0@hxr4?13zj#oWwPS(7Ro|v z>Mp@Kmxo79q|}!5qtX2-O@U&&@6s~!I&)1WQIl?lTnh6UdKT_1R640S4~f=_xoN3- zI+O)$R@RjV$F=>Ti7BlnG1-cFKCC(t|Qjm{SalS~V-tX#+2ekRhwmN zZr`8{QF6y~Z!D|{=1*2D-JUa<(1Z=;!Ei!KiRNH?o{p5o3crFF=_pX9O-YyJchr$~ zRC`+G+8kx~fD2k*ZIiiIGR<8r&M@3H?%JVOfE>)})7ScOd&?OjgAGT@WVNSCZ8N(p zuQG~76GE3%(%h1*vUXg$vH{ua0b`sQ4f0*y=u~lgyb^!#CcPJa2mkSEHGLsnO^kb$ zru5_l#nu=Y{rSMWiYx?nO{8I!gH+?wEj~UM?IrG}E|bRIBUM>UlY<`T1EHpRr36vv zBi&dG8oxS|J$!zoaq{+JpJy+O^W(nt*|#g32bd&K^w-t>!Vu9N!k9eA8r!Xc{utY> zg9aZ(D2E0gL#W0MdjwES-7~Wa8iubPrd?8-$C4BP?*wok&O8+ykOx{P=Izx+G~hM8 z*9?BYz!T8~dzcZr#ux8kS7u7r@A#DogBH8km8Ry4slyie^n|GrTbO|cLhpqgMdsjX zJ_LdmM#I&4LqqsOUIXK8gW;V0B(7^$y#h3h>J0k^WJfAMeYek%Y-Dcb_+0zPJez!GM zAmJ1u;*rK=FNM0Nf}Y!!P9c4)HIkMnq^b;JFd!S3?_Qi2G#LIQ)TF|iHl~WKK6JmK zbv7rPE6VkYr_%_BT}CK8h=?%pk@3cz(UrZ{@h40%XgThP*-Oeo`T0eq9 zA8BnWZKzCy5e&&_GEsU4*;_k}(8l_&al5K-V*BFM=O~;MgRkYsOs%9eOY6s6AtE*<7GQAR2ulC3RAJrG_P1iQK5Z~&B z&f8X<>yJV6)oDGIlS$Y*D^Rj(cszTy5c81a5IwBr`BtnC6_e`ArI8CaTX_%rx7;cn zR-0?J_LFg*?(#n~G8cXut(1nVF0Oka$A$1FGcERU<^ggx;p@CZc?3UB41RY+wLS`LWFNSs~YP zuw1@DNN3lTd|jDL7gjBsd9}wIw}4xT2+8dBQzI00m<@?c2L%>}QLfK5%r!a-iII`p zX@`VEUH)uj^$;7jVUYdADQ2k*!1O3WdfgF?OMtUXNpQ1}QINamBTKDuv19^{$`8A1 zeq%q*O0mi@(%sZU>Xdb0Ru96CFqk9-L3pzLVsMQ`Xpa~N6CR{9Rm2)A|CI21L(%GW zh&)Y$BNHa=FD+=mBw3{qTgw)j0b!Eahs!rZnpu)z!!E$*eXE~##yaXz`KE5(nQM`s zD!$vW9XH)iMxu9R>r$VlLk9oIR%HxpUiW=BK@4U)|1WNQ=mz9a z^!KkO=>GaJ!GBXm{KJj^;kh-MkUlEQ%lza`-G&}C5y1>La1sR6hT=d*NeCnuK%_LV zOXt$}iP6(YJKc9j-Fxq~*ItVUqljQ8?oaysB-EYtFQp9oxZ|5m0^Hq(qV!S+hq#g( z?|i*H2MIr^Kxgz+3vIljQ*Feejy6S4v~jKEPTF~Qhq!(ms5>NGtRgO5vfPPc4Z^AM zTj!`5xEreIN)vaNxa|q6qWdg>+T`Ol0Uz)ckXBXEGvPNEL3R8hB3=C5`@=SYgAju1 z!)UBr{2~=~xa{b8>x2@C7weRAEuatC)3pkRhT#pMPTpSbA|tan%U7NGMvzmF?c!V8 z=pEWxbdXbTAGtWTyI?Fml%lEr-^AE}w#l(<7OIw;ctw}imYax&vR4UYNJZK6P7ZOd zP87XfhnUHxCUHhM@b*NbTi#(-8|wcv%3BGNs#zRCVV(W?1Qj6^PPQa<{yaBwZ`+<`w|;rqUY_C z&AeyKwwf*q#OW-F()lir=T^<^wjK65Lif$puuU5+tk$;e_EJ;Lu+pH>=-8=PDhkBg z8cWt%@$Sc#C6F$Vd+0507;{OOyT7Hs%nKS88q-W!$f~9*WGBpHGgNp}=C*7!RiZ5s zn1L_DbKF@B8kwhDiLKRB@lsXVVLK|ph=w%_`#owlf@s@V(pa`GY$8h%;-#h@TsO|Y8V=n@*!Rog7<7Cid%apR|x zOjhHCyfbIt%+*PCveTEcuiDi%Wx;O;+K=W?OFUV%)%~6;gl?<0%)?snDDqIvkHF{ zyI02)+lI9ov42^hL>ZRrh*HhjF9B$A@=H94iaBESBF=eC_KT$8A@uB^6$~o?3Wm5t1OIaqF^~><2?4e3c&)@wKn9bD? zoeCs;H>b8DL^F&>Xw-xjZEUFFTv>JD^O#1E#)CMBaG4DX9bD(Wtc8Rzq}9soQ8`jf zeSnHOL}<+WVSKp4kkq&?SbETjq6yr@4%SAqOG=9E(3YeLG9dtV+8vmzq+6PFPk{L; z(&d++iu=^F%b+ea$i2UeTC{R*0Isk;vFK!no<;L+(`y`3&H-~VTdKROkdyowo1iqR zbVW(3`+(PQ2>TKY>N!jGmGo7oeoB8O|P_!Ic@ zZ^;3dnuXo;WJ?S+)%P>{Hcg!Jz#2SI(s&dY4QAy_vRlmOh)QHvs_7c&zkJCmJGVvV zX;Mtb>QE+xp`KyciG$Cn*0?AK%-a|=o!+7x&&yzHQOS>8=B*R=niSnta^Pxp1`=md z#;$pS$4WCT?mbiCYU?FcHGZ#)kHVJTTBt^%XE(Q};aaO=Zik0UgLcc0I(tUpt(>|& zcxB_|fxCF7>&~5eJ=Dpn&5Aj{A^cV^^}(7w#p;HG&Q)EaN~~EqrE1qKrMAc&WXIE;>@<&)5;gD2?={Xf@Mvn@OJKw=8Mgn z!JUFMwD+s==JpjhroT&d{$kQAy%+d`a*XxDEVxy3`NHzmITrE`o!;5ClXNPb4t*8P zzAivdr{j_v!=9!^?T3y?gzmqDWX6mkzhIzJ-3S{T5bcCFMr&RPDryMcdwbBuZbsgN zGrp@^i?rcfN7v0NKGzDPGE#4yszxu=I_`MI%Z|10nFjU-UjQXXA?k8Pk|OE<(?ae) zE%vG#eZAlj*E7_3dx#Zz4kMLj>H^;}33UAankJiDy5ZvEhrjr`!9eMD8COp}U*hP+ zF}KIYx@pkccIgyxFm#LNw~G&`;o&5)2`5aogs`1~7cMZQ7zj!%L4E`2yzlQN6REX20&O<9 zKV6fyr)TScJPPzNTC2gL+0x#=u>(({{D7j)c-%tvqls3#Y?Z1m zV5WUE)zdJ{$p>yX;^P!UcXP?UD~YM;IRa#Rs5~l+*$&nO(;Ers`G=0D!twR(0GF@c zHl9E5DQI}Oz74n zfKP>&$q0($T4y$6w(p=ERAFh+>n%iaeRA%!T%<^+pg?M)@ucY<&59$x9M#n+V&>}=nO9wCV{O~lg&v#+jcUj(tQ z`0u1YH)-`U$15a{pBkGyPL0THv1P|4e@pf@3IBZS4dVJPo#H>pWq%Lr0YS-SeWash z8R7=jb28KPMI|_lo#GEO|5B?N_e``H*23{~a!AmUJ+fb4HX-%QI@lSEUxKlGV7z7Q zSKw@-TR>@1RL%w{x}dW#k1NgW+q4yt2Xf1J62Bx*O^WG8OJ|FqI4&@d3_o8Id@*)4 zYrk=>@!wv~mh7YWv*bZhxqSmFh2Xq)o=m;%n$I?GSz49l1$xRpPu_^N(vZ>*>Z<04 z2+rP70oM=NDysd!@fQdM2OcyT?3T^Eb@lIC-UG=Bw{BjQ&P`KCv$AcJ;?`vdZ4){d z&gkoUK{$!$$K`3*O-jyM1~p-7T*qb)Ys>Myt^;#1&a%O@x8A+E>! zY8=eD`ZG)LVagDLBeHg>=atOG?Kr%h4B%E6m@J^C+U|y)XX@f z8oyJDW|9g=<#f<{JRr{y#~euMnv)`7j=%cHWLc}ngjq~7k**6%4u>Px&W%4D94(r* z+akunK}O0DC2A%Xo9jyF;DobX?!1I(7%}@7F>i%&nk*LMO)bMGg2N+1iqtg+r(70q zF5{Msgsm5GS7DT`kBsjMvOrkx&|EU!{{~gL4d2MWrAT=KBQ-^zQCUq{5PD1orxlIL zq;CvlWx#f1NWvh`hg011I%?T_s!e38l*lWVt|~z-PO4~~1g)SrJ|>*tXh=QfXT)%( z+ex+inPvD&O4Ur;JGz>$sUOnWdpSLcm1X%aQDw4{dB!cnj`^muI$CJ2%p&-kULVCE z>$eMR36kN$wCPR+OFDM3-U(VOrp9k3)lI&YVFqd;Kpz~K)@Fa&FRw}L(SoD z9B4a+hQzZT-BnVltst&=kq6Y(f^S4hIGNKYBgMxGJ^;2yrO}P3;r)(-I-CZ)26Y6? z&rzHI_1GCvGkgy-t1E;r^3Le30|%$ebDRu2+gdLG)r=A~Qz`}~&L@aGJ{}vVs_GE* zVUjFnzHiXfKQbpv&bR&}l2bzIjAooB)=-XNcYmrGmBh(&iu@o!^hn0^#}m2yZZUK8 zufVm7Gq0y`Mj;9b>`c?&PZkU0j4>IL=UL&-Lp3j&47B5pAW4JceG{!XCA)kT<%2nqCxj<)uy6XR_uws~>_MEKPOpAQ!H zkn>FKh)<9DwwS*|Y(q?$^N!6(51O0 z^JM~Ax{AI1Oj$fs-S5d4T7Z_i1?{%0SsIuQ&r8#(JA=2iLcTN+?>wOL532%&dMYkT z*T5xepC+V6zxhS@vNbMoi|i)=rpli@R9~P!39tWbSSb904ekv7D#quKbgFEMTb48P zuq(VJ+&L8aWU(_FCD$3^uD!YM%O^K(dvy~Wm2hUuh6bD|#(I39Xt>N1Y{ZqXL`Fg6 zKQ?T2htHN!(Bx;tV2bfTtIj7e)liN-29s1kew>v(D^@)#v;}C4-G=7x#;-dM4yRWm zyY`cS21ulzMK{PoaQ6xChEZ}o_#}X-o}<&0)$1#3we?+QeLt;aVCjeA)hn!}UaKt< zat1fHEx13y-rXNMvpUUmCVzocPmN~-Y4(YJvQ#db)4|%B!rBsgAe+*yor~}FrNH08 z3V!97S}D7d$zbSD{$z;@IYMxM6aHdypIuS*pr_U6;#Y!_?0i|&yU*@16l z*dcMqDQgfNBf}?quiu4e>H)yTVfsp#f+Du0@=Kc41QockXkCkvu>FBd6Q+@FL!(Yx z2`YuX#eMEiLEDhp+9uFqME_E^faV&~9qjBHJkIp~%$x^bN=N)K@kvSVEMdDuzA0sn z88CBG?`RX1@#hQNd`o^V{37)!w|nA)QfiYBE^m=yQKv-fQF+UCMcuEe1d4BH7$?>b zJl-r9@0^Ie=)guO1vOd=i$_4sz>y3x^R7n4ED!5oXL3@5**h(xr%Hv)_gILarO46q+MaDOF%ChaymKoI6JU5Pg;7#2n9-18|S1;AK+ zgsn6;k6-%!QD>D?cFy}8F;r@z8H9xN1jsOBw2vQONVqBVEbkiNUqgw~*!^##ht>w0 zUOykwH=$LwX2j&nLy=@{hr)2O&-wm-NyjW7n~Zs9UlH;P7iP3 zI}S(r0YFVYacnKH(+{*)Tbw)@;6>%=&Th=+Z6NHo_tR|JCI8TJiXv2N7ei7M^Q+RM z?9o`meH$5Yi;@9XaNR#jIK^&{N|DYNNbtdb)XW1Lv2k{E>;?F`#Pq|&_;gm~&~Zc9 zf+6ZE%{x4|{YdtE?a^gKyzr}dA>OxQv+pq|@IXL%WS0CiX!V zm$fCePA%lU{%pTKD7|5NJHeXg=I0jL@$tOF@K*MI$)f?om)D63K*M|r`gb9edD1~Y zc|w7N)Y%do7=0{RC|AziW7#am$)9jciRJ?IWl9PE{G3U+$%FcyKs_0Cgq`=K3@ttV z9g;M!3z~f_?P%y3-ph%vBMeS@p7P&Ea8M@97+%XEj*(1E6vHj==d zjsoviB>j^$_^OI_DEPvFkVo(BGRo%cJeD){6Uckei=~1}>sp299|IRjhXe)%?uP0I zF5+>?0#Ye}T^Y$u_rc4=lPcq4K^D(TZG-w30-YiEM=dcK+4#o*>lJ8&JLi+3UcpZk z!^?95S^C0ja^jwP`|{<+3cBVog$(mRdQmadS+Vh~z zS@|P}=|z3P6uS+&@QsMp0no9Od&27O&14zHXGAOEy zh~OKpymK5C%;LLb467@KgIiVwYbYd6wFxI{0-~MOGfTq$nBTB!{SrWmL9Hs}C&l&l#m?s*{tA?BHS4mVKHAVMqm63H<|c5n0~k)-kbg zXidai&9ZUy0~WFYYKT;oe~rytRk?)r8bptITsWj(@HLI;@=v5|XUnSls7$uaxFRL+ zRVMGuL3w}NbV1`^=Pw*0?>bm8+xfeY(1PikW*PB>>Tq(FR`91N0c2&>lL2sZo5=VD zQY{>7dh_TX98L2)n{2OV=T10~*YzX27i2Q7W86M4$?gZIXZaBq#sA*{PH8){|GUi;oM>e?ua7eF4WFuFYZSG| zze?srg|5Ti8Og{O zeFxuw9!U+zhyk?@w zjsA6(oKD=Ka;A>Ca)oPORxK+kxH#O@zhC!!XS4@=swnuMk>t+JmLmFiE^1aX3f<)D@`%K0FGK^gg1a1j>zi z2KhV>sjU7AX3F$SEqrXSC}fRx64GDoc%!u2Yag68Lw@w9v;xOONf@o)Lc|Uh3<21ctTYu-mFZuHk*+R{GjXHIGq3p)tFtQp%TYqD=j1&y)>@zxoxUJ!G@ zgI0XKmP6MNzw>nRxK$-Gbzs}dyfFzt>#5;f6oR27ql!%+{tr+(`(>%51|k`ML} zY4eE)Lxq|JMas(;JibNQds1bUB&r}ydMQXBY4x(^&fY_&LlQC)3hylc$~8&~|06-D z#T+%66rYbHX%^KuqJED_wuGB+=h`nWA!>1n0)3wZrBG3%`b^Ozv6__dNa@%V14|!D zQ?o$z5u0^8`giv%qE!BzZ!3j;BlDlJDk)h@9{nSQeEk!z9RGW) z${RSF3phEM*ce*>Xdp}585vj$|40=&S{S-GTiE?Op*vY&Lvr9}BO$XWy80IF+6@%n z5*2ueT_g@ofP#u5pxb7n*fv^Xtt7&?SRc{*2Ka-*!BuOpf}neHGCiHy$@Ka1^Dint z;DkmIL$-e)rj4o2WQV%Gy;Xg(_Bh#qeOsTM2f@KEe~4kJ8kNLQ+;(!j^bgJMcNhvklP5Z6I+9Fq@c&D~8Fb-4rmDT!MB5QC{Dsb;BharP*O;SF4& zc$wj-7Oep7#$WZN!1nznc@Vb<_Dn%ga-O#J(l=OGB`dy=Sy&$(5-n3zzu%d7E#^8`T@}V+5B;PP8J14#4cCPw-SQTdGa2gWL0*zKM z#DfSXs_iWOMt)0*+Y>Lkd=LlyoHjublNLefhKBv@JoC>P7N1_#> zv=mLWe96%EY;!ZGSQDbZWb#;tzqAGgx~uk+-$+2_8U`!ypbwXl z^2E-FkM1?lY@yt8=J3%QK+xaZ6ok=-y%=KXCD^0r!5vUneW>95PzCkOPO*t}p$;-> ze5j-BLT_;)cZQzR2CEsm@rU7GZfFtdp*a|g4wDr%8?2QkIGasRfDWT-Dvy*U{?IHT z*}wGnzdlSptl#ZF^sf)KT|BJs&kLG91^A6ls{CzFprZ6-Y!V0Xysh%9p%iMd7HLsS zN+^Un$tDV)T@i!v?3o0Fsx2qI(AX_$dDkBzQ@fRM%n zRXk6hb9Py#JXUs+7)w@eo;g%QQ95Yq!K_d=z{0dGS+pToEI6=Bo8+{k$7&Z zo4>PH(`ce8E-Ps&uv`NQ;U$%t;w~|@E3WVOCi~R4oj5wP?%<*1C%}Jq%a^q~T7u>K zML5AKfQDv6>PuT`{SrKHRAF+^&edg6+5R_#H?Lz3iGoWo#PCEd0DS;)2U({{X#zU^ zw_xv{4x7|t!S)>44J;KfA|DC?;uQ($l+5Vp7oeqf7{GBF9356nx|&B~gs+@N^gSdd zvb*>&W)|u#F{Z_b`f#GVtQ`pYv3#||N{xj1NgB<#=Odt6{eB%#9RLt5v zIi|0u70`#ai}9fJjKv7dE!9ZrOIX!3{$z_K5FBd-Kp-&e4(J$LD-)NMTp^_pB`RT; zftVVlK2g@+1Ahv2$D){@Y#cL#dUj9*&%#6 zd2m9{1NYp>)6=oAvqdCn5#cx{AJ%S8skUgMglu2*IAtd+z1>B&`MuEAS(D(<6X#Lj z?f4CFx$)M&$=7*>9v1ER4b6!SIz-m0e{o0BfkySREchp?WdVPpQCh!q$t>?rL!&Jg zd#heM;&~A}VEm8Dvy&P|J*eAV&w!&Nx6HFV&B8jJFVTmgLaswn!cx$&%JbTsloz!3 zMEz1d`k==`Ueub_JAy_&`!ogbwx27^ZXgFNAbx=g_I~5nO^r)}&myw~+yY*cJl4$I znNJ32M&K=0(2Dj_>@39`3=FX!v3nZHno_@q^!y}%(yw0PqOo=);6Y@&ylVe>nMOZ~ zd>j#QQSBn3oaWd;qy$&5(5H$Ayi)0haAYO6TH>FR?rhqHmNOO+(})NB zLI@B@v0)eq!ug`>G<@htRlp3n!EpU|n+G+AvXFrWSUsLMBfL*ZB`CRsIVHNTR&b?K zxBgsN0BjfB>UVcJ|x%=-zb%OV7lmZc& zxiupadZVF7)6QuhoY;;FK2b*qL0J-Rn-8!X4ZY$-ZSUXV5DFd7`T41c(#lAeLMoeT z4%g655v@7AqT!i@)Edt5JMbN(=Q-6{=L4iG8RA%}w;&pKmtWvI4?G9pVRp|RTw`g0 zD5c12B&A2&P6Ng~8WM2eIW=wxd?r7A*N+&!Be7PX3s|7~z=APxm=A?5 zt>xB4WG|*Td@VX{Rs)PV0|yK`oI3^xn(4c_j&vgxk_Y3o(-`_5o`V zRTghg6%l@(qodXN;dB#+OKJEEvhfcnc#BeO2|E(5df-!fKDZ!%9!^BJ_4)9P+9Dq5 zK1=(v?KmIp34r?z{NEWnLB3Px{XYwy-akun4F7xTRr2^zeYW{gcK9)>aJDdU5;w5@ zak=<+-PLH-|04pelTb%ULpuuuJC7DgyT@D|p{!V!0v3KpDnRjANN12q6SUR3mb9<- z>2r~IApQGhstZ!3*?5V z8#)hJ0TdZg0M-BK#nGFP>$i=qk82DO z7h;Ft!D5E15OgW)&%lej*?^1~2=*Z5$2VX>V{x8SC+{i10BbtUk9@I#Vi&hX)q
Q!LwySI{Bnv%Sm)yh{^sSVJ8&h_D-BJ_YZe5eCaAWU9b$O2c z$T|{vWVRtOL!xC0DTc(Qbe`ItNtt5hr<)VijD0{U;T#bUEp381_y`%ZIav?kuYG{iyYdEBPW=*xNSc;Rlt6~F4M`5G+VtOjc z*0qGzCb@gME5udTjJA-9O<&TWd~}ysBd(eVT1-H82-doyH9RST)|+Pb{o*;$j9Tjs zhU!IlsPsj8=(x3bAKJTopW3^6AKROHR^7wZ185wJGVhA~hEc|LP;k7NEz-@4p5o}F z`AD6naG3(n=NF9HTH81=F+Q|JOz$7wm9I<+#BSmB@o_cLt2GkW9|?7mM;r!JZp89l zbo!Hp8=n!XH1{GwaDU+k)pGp`C|cXkCU5%vcH)+v@0eK>%7gWxmuMu9YLlChA|_D@ zi#5zovN_!a-0?~pUV-Rj*1P)KwdU-LguR>YM&*Nen+ln8Q$?WFCJg%DY%K}2!!1FE zDv-A%Cbwo^p(lzac&_TZ-l#9kq`mhLcY3h9ZTUVCM(Ad&=EriQY5{jJv<5K&g|*Lk zgV%ILnf1%8V2B0E&;Sp4sYbYOvvMebLwYwzkRQ#F8GpTQq#uv=J`uaSJ34OWITeSGo6+-8Xw znCk*n{kdDEi)Hi&u^)~cs@iyCkFWB2SWZU|Uc%^43ZIZQ-vWNExCCtDWjqHs;;tWf$v{}0{p0Rvxkq``)*>+Akq%|Na zA`@~-Vfe|+(AIlqru+7Ceh4nsVmO9p9jc8}HX^W&ViBDXT+uXbT#R#idPn&L>+#b6 zflC-4C5-X;kUnR~L>PSLh*gvL68}RBsu#2l`s_9KjUWRhiqF`j)`y`2`YU(>3bdBj z?>iyjEhe-~$^I5!nn%B6Wh+I`FvLNvauve~eX<+Ipl&04 zT}};W&1a3%W?dJ2=N#0t?e+aK+%t}5q%jSLvp3jZ%?&F}nOOWr>+{GFIa%wO_2`et z=JzoRR~}iKuuR+azPI8;Gf9)z3kyA4EIOSl!sRR$DlW}0>&?GbgPojmjmnln;cTqCt=ADbE zZ8GAnoM+S1(5$i8^O4t`ue;vO4i}z0wz-QEIVe5_u03;}-!G1NyY8;h^}y;tzY}i5 zqQr#Ur3Fy8sSa$Q0ys+f`!`+>9WbvU_I`Sj;$4{S>O3?#inLHCrtLy~!s#WXV=oVP zeE93*Nc`PBi4q@%Ao$x4lw9vLHM!6mn3-b_cebF|n-2vt-zYVF_&sDE--J-P;2WHo z+@n2areE0o$LjvjlV2X7ZU@j+`{*8zq`JR3gKF#EW|#+{nMyo-a>nFFTg&vhyT=b} zDa8+v0(Dgx0yRL@ZXOYIlVSZ0|MFizy0VPW8;AfA5|pe!#j zX}Py^8fl5SyS4g1WSKKtnyP+_PoOwMMwu`(i@Z)diJp~U54*-miOchy7Z35eL>^M z4p<-aIxH4VUZgS783@H%M7P9hX>t{|RU7$n4T(brCG#h9e9p! z+o`i;EGGq3&pF;~5V~eBD}lC)>if$w%Vf}AFxGqO88|ApfHf&Bvu+xdG)@vuF}Yvk z)o;~k-%+0K0g+L`Wala!$=ZV|z$e%>f0%XoLib%)!R^RoS+{!#X?h-6uu zF&&KxORdZU&EwQFITIRLo(7TA3W}y6X{?Y%y2j0It!ekU#<)$qghZtpcS>L3uh`Uj z7GY;6f$9qKynP#oS3$$a{p^{D+0oJQ71`1?OAn_m8)UGZmj3l*ZI)`V-a>MKGGFG< z&^jg#Ok%(hhm>hSrZ5;Qga4u(?^i>GiW_j9%_7M>j(^|Om$#{k+^*ULnEgzW_1gCICtAD^WpC`A z{9&DXkG#01Xo)U$OC(L5Y$DQ|Q4C6CjUKk1UkPj$nXH##J{c8e#K|&{mA*;b$r0E4 zUNo0jthwA(c&N1l=PEe8Rw_8cEl|-eya9z&H3#n`B$t#+aJ03RFMzrV@gowbe8v(c zIFM60^0&lCFO10NU4w@|61xiZ4CVXeaKjd;d?sv52XM*lS8XiVjgWpRB;&U_C0g+`6B5V&w|O6B*_q zsATxL!M}+$He)1eOWECce#eS@2n^xhlB4<_Nn?yCVEQWDs(r`|@2GqLe<#(|&P0U? z$7V5IgpWf09uIf_RazRwC?qEqRaHyL?iiS05UiGesJy%^>-C{{ypTBI&B0-iUYhk> zIk<5xpsuV@g|z(AZD+C-;A!fTG=df1=<%nxy(a(IS+U{ME4ZbDEBtcD_3V=icT6*_ z)>|J?>&6%nvHhZERBtjK+s4xnut*@>GAmA5m*OTp$!^CHTr}vM4n(X1Q*;{e-Rd2BCF-u@1ZGm z!S8hJ6L=Gl4T_SDa7Xx|-{4mxveJg=ctf`BJ*fy!yF6Dz&?w(Q_6B}WQVtNI!BVBC zKfX<>7vd6C96}XAQmF-Jd?1Q4eTfRB3q7hCh0f!(JkdWT5<{iAE#dKy*Jxq&3a1@~ z8C||Dn2mFNyrUV|<-)C^_y7@8c2Fz+2jrae9deBDu;U}tJ{^xAdxCD248(k;dCJ%o z`y3sADe>U%suxwwv~8A1+R$VB=Q?%U?4joI$um;aH+eCrBqpn- z%79D_7rb;R-;-9RTrwi9dPlg8&@tfWhhZ(Vx&1PQ+6(huX`;M9x~LrW~~#3{j0Bh2kDU$}@!fFQej4VGkJv?M4rU^x!RU zEwhu$!CA_iDjFjrJa`aocySDX16?~;+wgav;}Zut6Mg%C4>}8FL?8)Kgwc(Qlj{@#2Pt0?G`$h7P#M+qoXtlV@d}%c&OzO+QYKK`kyXaK{U(O^2DyIXCZlNQjt0^8~8JzNGrIxhj}}M z&~QZlbx%t;MJ(Vux;2tgNKGlAqphLq%pd}JG9uoVHUo?|hN{pLQ6Em%r*+7t^<);X zm~6=qChlNAVXNN*Sow->*4;}T;l;D1I-5T{Bif@4_}=>l`tK;qqDdt5zvisCKhMAH z#r}`)7VW?LZqfdmXQ%zo5bJ00{Xb9^YKrk0Nf|oIW*K@(=`o2Vndz}ZDyk{!u}PVx zzd--+_WC*U{~DH3{?GI64IB+@On&@9X>EUAo&L+G{L^dozaI4C3G#2wr~hseW@K&g zKWs{uHu-9Je!3;4pE>eBltKUXb^*hG8I&413)$J&{D4N%7PcloU6bn%jPxJyQL?g* z9g+YFFEDiE`8rW^laCNzQmi7CTnPfwyg3VDHRAl>h=In6jeaVOP@!-CP60j3+#vpL zEYmh_oP0{-gTe7Or`L6x)6w?77QVi~jD8lWN@3RHcm80iV%M1A!+Y6iHM)05iC64tb$X2lV_%Txk@0l^hZqi^%Z?#- zE;LE0uFx)R08_S-#(wC=dS&}vj6P4>5ZWjhthP=*Hht&TdLtKDR;rXEX4*z0h74FA zMCINqrh3Vq;s%3MC1YL`{WjIAPkVL#3rj^9Pj9Ss7>7duy!9H0vYF%>1jh)EPqvlr6h%R%CxDsk| z!BACz7E%j?bm=pH6Eaw{+suniuY7C9Ut~1cWfOX9KW9=H><&kQlinPV3h9R>3nJvK z4L9(DRM=x;R&d#a@oFY7mB|m8h4692U5eYfcw|QKwqRsshN(q^v$4$)HgPpAJDJ`I zkqjq(8Cd!K!+wCd=d@w%~e$=gdUgD&wj$LQ1r>-E=O@c ze+Z$x{>6(JA-fNVr)X;*)40Eym1TtUZI1Pwwx1hUi+G1Jlk~vCYeXMNYtr)1?qwyg zsX_e*$h?380O00ou?0R@7-Fc59o$UvyVs4cUbujHUA>sH!}L54>`e` zHUx#Q+Hn&Og#YVOuo*niy*GU3rH;%f``nk#NN5-xrZ34NeH$l`4@t);4(+0|Z#I>Y z)~Kzs#exIAaf--65L0UHT_SvV8O2WYeD>Mq^Y6L!Xu8%vnpofG@w!}R7M28?i1*T&zp3X4^OMCY6(Dg<-! zXmcGQrRgHXGYre7GfTJ)rhl|rs%abKT_Nt24_Q``XH{88NVPW+`x4ZdrMuO0iZ0g` z%p}y};~T5gbb9SeL8BSc`SO#ixC$@QhXxZ=B}L`tP}&k?1oSPS=4%{UOHe0<_XWln zwbl5cn(j-qK`)vGHY5B5C|QZd5)W7c@{bNVXqJ!!n$^ufc?N9C-BF2QK1(kv++h!>$QbAjq)_b$$PcJdV+F7hz0Hu@ zqj+}m0qn{t^tD3DfBb~0B36|Q`bs*xs|$i^G4uNUEBl4g;op-;Wl~iThgga?+dL7s zUP(8lMO?g{GcYpDS{NM!UA8Hco?#}eNEioRBHy4`mq!Pd-9@-97|k$hpEX>xoX+dY zDr$wfm^P&}Wu{!%?)U_(%Mn79$(ywvu*kJ9r4u|MyYLI_67U7%6Gd_vb##Nerf@>& z8W11z$$~xEZt$dPG}+*IZky+os5Ju2eRi;1=rUEeIn>t-AzC_IGM-IXWK3^6QNU+2pe=MBn4I*R@A%-iLDCOHTE-O^wo$sL_h{dcPl=^muAQb`_BRm};=cy{qSkui;`WSsj9%c^+bIDQ z0`_?KX0<-=o!t{u(Ln)v>%VGL z0pC=GB7*AQ?N7N{ut*a%MH-tdtNmNC+Yf$|KS)BW(gQJ*z$d{+{j?(e&hgTy^2|AR9vx1Xre2fagGv0YXWqtNkg*v%40v?BJBt|f9wX5 z{QTlCM}b-0{mV?IG>TW_BdviUKhtosrBqdfq&Frdz>cF~yK{P@(w{Vr7z2qKFwLhc zQuogKO@~YwyS9%+d-zD7mJG~@?EFJLSn!a&mhE5$_4xBl&6QHMzL?CdzEnC~C3$X@ zvY!{_GR06ep5;<#cKCSJ%srxX=+pn?ywDwtJ2{TV;0DKBO2t++B(tIO4)Wh`rD13P z4fE$#%zkd=UzOB74gi=-*CuID&Z3zI^-`4U^S?dHxK8fP*;fE|a(KYMgMUo`THIS1f!*6dOI2 zFjC3O=-AL`6=9pp;`CYPTdVX z8(*?V&%QoipuH0>WKlL8A*zTKckD!paN@~hh zmXzm~qZhMGVdQGd=AG8&20HW0RGV8X{$9LldFZYm zE?}`Q3i?xJRz43S?VFMmqRyvWaS#(~Lempg9nTM$EFDP(Gzx#$r)W&lpFKqcAoJh-AxEw$-bjW>`_+gEi z2w`99#UbFZGiQjS8kj~@PGqpsPX`T{YOj`CaEqTFag;$jY z8_{Wzz>HXx&G*Dx<5skhpETxIdhKH?DtY@b9l8$l?UkM#J-Snmts7bd7xayKTFJ(u zyAT&@6cAYcs{PBfpqZa%sxhJ5nSZBPji?Zlf&}#L?t)vC4X5VLp%~fz2Sx<*oN<7` z?ge=k<=X7r<~F7Tvp9#HB{!mA!QWBOf%EiSJ6KIF8QZNjg&x~-%e*tflL(ji_S^sO ztmib1rp09uon}RcsFi#k)oLs@$?vs(i>5k3YN%$T(5Or(TZ5JW9mA6mIMD08=749$ z!d+l*iu{Il7^Yu}H;lgw=En1sJpCKPSqTCHy4(f&NPelr31^*l%KHq^QE>z>Ks_bH zjbD?({~8Din7IvZeJ>8Ey=e;I?thpzD=zE5UHeO|neioJwG;IyLk?xOz(yO&0DTU~ z^#)xcs|s>Flgmp;SmYJ4g(|HMu3v7#;c*Aa8iF#UZo7CvDq4>8#qLJ|YdZ!AsH%^_7N1IQjCro

K7UpUK$>l@ zw`1S}(D?mUXu_C{wupRS-jiX~w=Uqqhf|Vb3Cm9L=T+w91Cu^ z*&Ty%sN?x*h~mJc4g~k{xD4ZmF%FXZNC;oVDwLZ_WvrnzY|{v8hc1nmx4^}Z;yriXsAf+Lp+OFLbR!&Ox?xABwl zu8w&|5pCxmu#$?Cv2_-Vghl2LZ6m7}VLEfR5o2Ou$x02uA-%QB2$c(c1rH3R9hesc zfpn#oqpbKuVsdfV#cv@5pV4^f_!WS+F>SV6N0JQ9E!T90EX((_{bSSFv9ld%I0&}9 zH&Jd4MEX1e0iqDtq~h?DBrxQX1iI0lIs<|kB$Yrh&cpeK0-^K%=FBsCBT46@h#yi!AyDq1V(#V}^;{{V*@T4WJ&U-NTq43w=|K>z8%pr_nC>%C(Wa_l78Ufib$r8Od)IIN=u>417 z`Hl{9A$mI5A(;+-Q&$F&h-@;NR>Z<2U;Y21>>Z;s@0V@SbkMQQj%_;~+qTuQ?c|AV zcWm3XZQHhP&R%QWarS%mJ!9R^&!_)*s(v+VR@I#QrAT}`17Y+l<`b-nvmDNW`De%y zrwTZ9EJrj1AFA>B`1jYDow}~*dfPs}IZMO3=a{Fy#IOILc8F0;JS4x(k-NSpbN@qM z`@aE_e}5{!$v3+qVs7u?sOV(y@1Os*Fgu`fCW9=G@F_#VQ%xf$hj0~wnnP0$hFI+@ zkQj~v#V>xn)u??YutKsX>pxKCl^p!C-o?+9;!Nug^ z{rP!|+KsP5%uF;ZCa5F;O^9TGac=M|=V z_H(PfkV1rz4jl?gJ(ArXMyWT4y(86d3`$iI4^l9`vLdZkzpznSd5Ikfrs8qcSy&>z zTIZgWZGXw0n9ibQxYWE@gI0(3#KA-dAdPcsL_|hg2@~C!VZDM}5;v_Nykfq!*@*Zf zE_wVgx82GMDryKO{U{D>vSzSc%B~|cjDQrt5BN=Ugpsf8H8f1lR4SGo#hCuXPL;QQ z#~b?C4MoepT3X`qdW2dNn& zo8)K}%Lpu>0tQei+{>*VGErz|qjbK#9 zvtd8rcHplw%YyQCKR{kyo6fgg!)6tHUYT(L>B7er5)41iG`j$qe*kSh$fY!PehLcD zWeKZHn<492B34*JUQh=CY1R~jT9Jt=k=jCU2=SL&&y5QI2uAG2?L8qd2U(^AW#{(x zThSy=C#>k+QMo^7caQcpU?Qn}j-`s?1vXuzG#j8(A+RUAY})F@=r&F(8nI&HspAy4 z4>(M>hI9c7?DCW8rw6|23?qQMSq?*Vx?v30U%luBo)B-k2mkL)Ljk5xUha3pK>EEj z@(;tH|M@xkuN?gsz;*bygizwYR!6=(Xgcg^>WlGtRYCozY<rFX2E>kaZo)O<^J7a`MX8Pf`gBd4vrtD|qKn&B)C&wp0O-x*@-|m*0egT=-t@%dD zgP2D+#WPptnc;_ugD6%zN}Z+X4=c61XNLb7L1gWd8;NHrBXwJ7s0ce#lWnnFUMTR& z1_R9Fin4!d17d4jpKcfh?MKRxxQk$@)*hradH2$3)nyXep5Z;B z?yX+-Bd=TqO2!11?MDtG0n(*T^!CIiF@ZQymqq1wPM_X$Iu9-P=^}v7npvvPBu!d$ z7K?@CsA8H38+zjA@{;{kG)#AHME>Ix<711_iQ@WWMObXyVO)a&^qE1GqpP47Q|_AG zP`(AD&r!V^MXQ^e+*n5~Lp9!B+#y3#f8J^5!iC@3Y@P`;FoUH{G*pj*q7MVV)29+j z>BC`a|1@U_v%%o9VH_HsSnM`jZ-&CDvbiqDg)tQEnV>b%Ptm)T|1?TrpIl)Y$LnG_ zzKi5j2Fx^K^PG1=*?GhK;$(UCF-tM~^=Z*+Wp{FSuy7iHt9#4n(sUuHK??@v+6*|10Csdnyg9hAsC5_OrSL;jVkLlf zHXIPukLqbhs~-*oa^gqgvtpgTk_7GypwH><53riYYL*M=Q@F-yEPLqQ&1Sc zZB%w}T~RO|#jFjMWcKMZccxm-SL)s_ig?OC?y_~gLFj{n8D$J_Kw%{r0oB8?@dWzn zB528d-wUBQzrrSSLq?fR!K%59Zv9J4yCQhhDGwhptpA5O5U?Hjqt>8nOD zi{)0CI|&Gu%zunGI*XFZh(ix)q${jT8wnnzbBMPYVJc4HX*9d^mz|21$=R$J$(y7V zo0dxdbX3N#=F$zjstTf*t8vL)2*{XH!+<2IJ1VVFa67|{?LP&P41h$2i2;?N~RA30LV`BsUcj zfO9#Pg1$t}7zpv#&)8`mis3~o+P(DxOMgz-V*(?wWaxi?R=NhtW}<#^Z?(BhSwyar zG|A#Q7wh4OfK<|DAcl9THc-W4*>J4nTevsD%dkj`U~wSUCh15?_N@uMdF^Kw+{agk zJ`im^wDqj`Ev)W3k3stasP`88-M0ZBs7;B6{-tSm3>I@_e-QfT?7|n0D~0RRqDb^G zyHb=is;IwuQ&ITzL4KsP@Z`b$d%B0Wuhioo1CWttW8yhsER1ZUZzA{F*K=wmi-sb#Ju+j z-l@In^IKnb{bQG}Ps>+Vu_W#grNKNGto+yjA)?>0?~X`4I3T@5G1)RqGUZuP^NJCq&^HykuYtMDD8qq+l8RcZNJsvN(10{ zQ1$XcGt}QH-U^WU!-wRR1d--{B$%vY{JLWIV%P4-KQuxxDeJaF#{eu&&r!3Qu{w}0f--8^H|KwE>)ORrcR+2Qf zb})DRcH>k0zWK8@{RX}NYvTF;E~phK{+F;MkIP$)T$93Ba2R2TvKc>`D??#mv9wg$ zd~|-`Qx5LwwsZ2hb*Rt4S9dsF%Cny5<1fscy~)d;0m2r$f=83<->c~!GNyb!U)PA; zq^!`@@)UaG)Ew(9V?5ZBq#c%dCWZrplmuM`o~TyHjAIMh0*#1{B>K4po-dx$Tk-Cq z=WZDkP5x2W&Os`N8KiYHRH#UY*n|nvd(U>yO=MFI-2BEp?x@=N<~CbLJBf6P)}vLS?xJXYJ2^<3KJUdrwKnJnTp{ zjIi|R=L7rn9b*D#Xxr4*R<3T5AuOS+#U8hNlfo&^9JO{VbH!v9^JbK=TCGR-5EWR@ zN8T-_I|&@A}(hKeL4_*eb!1G8p~&_Im8|wc>Cdir+gg90n1dw?QaXcx6Op_W1r=axRw>4;rM*UOpT#Eb9xU1IiWo@h?|5uP zka>-XW0Ikp@dIe;MN8B01a7+5V@h3WN{J=HJ*pe0uwQ3S&MyWFni47X32Q7SyCTNQ z+sR!_9IZa5!>f&V$`q!%H8ci!a|RMx5}5MA_kr+bhtQy{-^)(hCVa@I!^TV4RBi zAFa!Nsi3y37I5EK;0cqu|9MRj<^r&h1lF}u0KpKQD^5Y+LvFEwM zLU@@v4_Na#Axy6tn3P%sD^5P#<7F;sd$f4a7LBMk zGU^RZHBcxSA%kCx*eH&wgA?Qwazm8>9SCSz_!;MqY-QX<1@p$*T8lc?@`ikEqJ>#w zcG``^CoFMAhdEXT9qt47g0IZkaU)4R7wkGs^Ax}usqJ5HfDYAV$!=6?>J6+Ha1I<5 z|6=9soU4>E))tW$<#>F ziZ$6>KJf0bPfbx_)7-}tMINlc=}|H+$uX)mhC6-Hz+XZxsKd^b?RFB6et}O#+>Wmw9Ec9) z{q}XFWp{3@qmyK*Jvzpyqv57LIR;hPXKsrh{G?&dRjF%Zt5&m20Ll?OyfUYC3WRn{cgQ?^V~UAv+5 z&_m#&nIwffgX1*Z2#5^Kl4DbE#NrD&Hi4|7SPqZ}(>_+JMz=s|k77aEL}<=0Zfb)a z%F(*L3zCA<=xO)2U3B|pcTqDbBoFp>QyAEU(jMu8(jLA61-H!ucI804+B!$E^cQQa z)_ERrW3g!B9iLb3nn3dlkvD7KsY?sRvls3QC0qPi>o<)GHx%4Xb$5a3GBTJ(k@`e@ z$RUa^%S15^1oLEmA=sayrP5;9qtf!Z1*?e$ORVPsXpL{jL<6E)0sj&swP3}NPmR%FM?O>SQgN5XfHE< zo(4#Cv11(%Nnw_{_Ro}r6=gKd{k?NebJ~<~Kv0r(r0qe4n3LFx$5%x(BKvrz$m?LG zjLIc;hbj0FMdb9aH9Lpsof#yG$(0sG2%RL;d(n>;#jb!R_+dad+K;Ccw!|RY?uS(a zj~?=&M!4C(5LnlH6k%aYvz@7?xRa^2gml%vn&eKl$R_lJ+e|xsNfXzr#xuh(>`}9g zLHSyiFwK^-p!;p$yt7$F|3*IfO3Mlu9e>Dpx8O`37?fA`cj`C0B-m9uRhJjs^mRp# zWB;Aj6|G^1V6`jg7#7V9UFvnB4((nIwG?k%c7h`?0tS8J3Bn0t#pb#SA}N-|45$-j z$R>%7cc2ebAClXc(&0UtHX<>pd)akR3Kx_cK+n<}FhzmTx!8e9^u2e4%x{>T6pQ`6 zO182bh$-W5A3^wos0SV_TgPmF4WUP-+D25KjbC{y_6W_9I2_vNKwU(^qSdn&>^=*t z&uvp*@c8#2*paD!ZMCi3;K{Na;I4Q35zw$YrW5U@Kk~)&rw;G?d7Q&c9|x<Hg|CNMsxovmfth*|E*GHezPTWa^Hd^F4!B3sF;)? z(NaPyAhocu1jUe(!5Cy|dh|W2=!@fNmuNOzxi^tE_jAtzNJ0JR-avc_H|ve#KO}#S z#a(8secu|^Tx553d4r@3#6^MHbH)vmiBpn0X^29xEv!Vuh1n(Sr5I0V&`jA2;WS|Y zbf0e}X|)wA-Pf5gBZ>r4YX3Mav1kKY(ulAJ0Q*jB)YhviHK)w!TJsi3^dMa$L@^{` z_De`fF4;M87vM3Ph9SzCoCi$#Fsd38u!^0#*sPful^p5oI(xGU?yeYjn;Hq1!wzFk zG&2w}W3`AX4bxoVm03y>ts{KaDf!}b&7$(P4KAMP=vK5?1In^-YYNtx1f#}+2QK@h zeSeAI@E6Z8a?)>sZ`fbq9_snl6LCu6g>o)rO;ijp3|$vig+4t} zylEo7$SEW<_U+qgVcaVhk+4k+C9THI5V10qV*dOV6pPtAI$)QN{!JRBKh-D zk2^{j@bZ}yqW?<#VVuI_27*cI-V~sJiqQv&m07+10XF+#ZnIJdr8t`9s_EE;T2V;B z4UnQUH9EdX%zwh-5&wflY#ve!IWt0UE-My3?L#^Bh%kcgP1q{&26eXLn zTkjJ*w+(|_>Pq0v8{%nX$QZbf)tbJaLY$03;MO=Ic-uqYUmUCuXD>J>o6BCRF=xa% z3R4SK9#t1!K4I_d>tZgE>&+kZ?Q}1qo4&h%U$GfY058s%*=!kac{0Z+4Hwm!)pFLR zJ+5*OpgWUrm0FPI2ib4NPJ+Sk07j(`diti^i#kh&f}i>P4~|d?RFb#!JN)~D@)beox}bw?4VCf^y*`2{4`-@%SFTry2h z>9VBc9#JxEs1+0i2^LR@B1J`B9Ac=#FW=(?2;5;#U$0E0UNag_!jY$&2diQk_n)bT zl5Me_SUvqUjwCqmVcyb`igygB_4YUB*m$h5oeKv3uIF0sk}~es!{D>4r%PC*F~FN3owq5e0|YeUTSG#Vq%&Gk7uwW z0lDo#_wvflqHeRm*}l?}o;EILszBt|EW*zNPmq#?4A+&i0xx^?9obLyY4xx=Y9&^G;xYXYPxG)DOpPg!i_Ccl#3L}6xAAZzNhPK1XaC_~ z!A|mlo?Be*8Nn=a+FhgpOj@G7yYs(Qk(8&|h@_>w8Y^r&5nCqe0V60rRz?b5%J;GYeBqSAjo|K692GxD4` zRZyM2FdI+-jK2}WAZTZ()w_)V{n5tEb@>+JYluDozCb$fA4H)$bzg(Ux{*hXurjO^ zwAxc+UXu=&JV*E59}h3kzQPG4M)X8E*}#_&}w*KEgtX)cU{vm9b$atHa;s>| z+L6&cn8xUL*OSjx4YGjf6{Eq+Q3{!ZyhrL&^6Vz@jGbI%cAM9GkmFlamTbcQGvOlL zmJ?(FI)c86=JEs|*;?h~o)88>12nXlpMR4@yh%qdwFNpct;vMlc=;{FSo*apJ;p}! zAX~t;3tb~VuP|ZW;z$=IHf->F@Ml)&-&Bnb{iQyE#;GZ@C$PzEf6~q}4D>9jic@mTO5x76ulDz@+XAcm35!VSu zT*Gs>;f0b2TNpjU_BjHZ&S6Sqk6V1370+!eppV2H+FY!q*n=GHQ!9Rn6MjY!Jc77A zG7Y!lFp8?TIHN!LXO?gCnsYM-gQxsm=Ek**VmZu7vnuufD7K~GIxfxbsQ@qv2T zPa`tvHB$fFCyZl>3oYg?_wW)C>^_iDOc^B7klnTOoytQH18WkOk)L2BSD0r%xgRSW zQS9elF^?O=_@|58zKLK;(f77l-Zzu}4{fXed2saq!5k#UZAoDBqYQS{sn@j@Vtp|$ zG%gnZ$U|9@u#w1@11Sjl8ze^Co=)7yS(}=;68a3~g;NDe_X^}yJj;~s8xq9ahQ5_r zxAlTMnep*)w1e(TG%tWsjo3RR;yVGPEO4V{Zp?=a_0R#=V^ioQu4YL=BO4r0$$XTX zZfnw#_$V}sDAIDrezGQ+h?q24St0QNug_?{s-pI(^jg`#JRxM1YBV;a@@JQvH8*>> zIJvku74E0NlXkYe_624>znU0J@L<-c=G#F3k4A_)*;ky!C(^uZfj%WB3-*{*B$?9+ zDm$WFp=0(xnt6`vDQV3Jl5f&R(Mp};;q8d3I%Kn>Kx=^;uSVCw0L=gw53%Bp==8Sw zxtx=cs!^-_+i{2OK`Q;913+AXc_&Z5$@z3<)So0CU3;JAv=H?@Zpi~riQ{z-zLtVL z!oF<}@IgJp)Iyz1zVJ42!SPHSkjYNS4%ulVVIXdRuiZ@5Mx8LJS}J#qD^Zi_xQ@>DKDr-_e#>5h3dtje*NcwH_h;i{Sx7}dkdpuW z(yUCjckQsagv*QGMSi9u1`Z|V^}Wjf7B@q%j2DQXyd0nOyqg%m{CK_lAoKlJ7#8M} z%IvR?Vh$6aDWK2W!=i?*<77q&B8O&3?zP(Cs@kapc)&p7En?J;t-TX9abGT#H?TW? ztO5(lPKRuC7fs}zwcUKbRh=7E8wzTsa#Z{a`WR}?UZ%!HohN}d&xJ=JQhpO1PI#>X zHkb>pW04pU%Bj_mf~U}1F1=wxdBZu1790>3Dm44bQ#F=T4V3&HlOLsGH)+AK$cHk6 zia$=$kog?)07HCL*PI6}DRhpM^*%I*kHM<#1Se+AQ!!xyhcy6j7`iDX7Z-2i73_n# zas*?7LkxS-XSqv;YBa zW_n*32D(HTYQ0$feV_Fru1ZxW0g&iwqixPX3=9t4o)o|kOo79V$?$uh?#8Q8e>4e)V6;_(x&ViUVxma+i25qea;d-oK7ouuDsB^ab{ zu1qjQ%`n56VtxBE#0qAzb7lph`Eb-}TYpXB!H-}3Ykqyp`otprp7{VEuW*^IR2n$Fb99*nAtqT&oOFIf z@w*6>YvOGw@Ja?Pp1=whZqydzx@9X4n^2!n83C5{C?G@|E?&$?p*g68)kNvUTJ)I6 z1Q|(#UuP6pj78GUxq11m-GSszc+)X{C2eo-?8ud9sB=3(D47v?`JAa{V(IF zPZQ_0AY*9M97>Jf<o%#O_%Wq}8>YM=q0|tGY+hlXcpE=Z4Od z`NT7Hu2hnvRoqOw@g1f=bv`+nba{GwA$Ak0INlqI1k<9!x_!sL()h?hEWoWrdU3w` zZ%%)VR+Bc@_v!C#koM1p-3v_^L6)_Ktj4HE>aUh%2XZE@JFMOn)J~c`_7VWNb9c-N z2b|SZMR4Z@E7j&q&9(6H3yjEu6HV7{2!1t0lgizD;mZ9$r(r7W5G$ky@w(T_dFnOD z*p#+z$@pKE+>o@%eT(2-p_C}wbQ5s(%Sn_{$HDN@MB+Ev?t@3dPy`%TZ!z}AThZSu zN<1i$siJhXFdjV zP*y|V<`V8t=h#XTRUR~5`c`Z9^-`*BZf?WAehGdg)E2Je)hqFa!k{V(u+(hTf^Yq& zoruUh2(^3pe)2{bvt4&4Y9CY3js)PUHtd4rVG57}uFJL)D(JfSIo^{P=7liFXG zq5yqgof0V8paQcP!gy+;^pp-DA5pj=gbMN0eW=-eY+N8~y+G>t+x}oa!5r>tW$xhI zPQSv=pi;~653Gvf6~*JcQ%t1xOrH2l3Zy@8AoJ+wz@daW@m7?%LXkr!bw9GY@ns3e zSfuWF_gkWnesv?s3I`@}NgE2xwgs&rj?kH-FEy82=O8`+szN ziHch`vvS`zNfap14!&#i9H@wF7}yIPm=UB%(o(}F{wsZ(wA0nJ2aD^@B41>>o-_U6 zUqD~vdo48S8~FTb^+%#zcbQiiYoDKYcj&$#^;Smmb+Ljp(L=1Kt_J!;0s%1|JK}Wi z;={~oL!foo5n8=}rs6MmUW~R&;SIJO3TL4Ky?kh+b2rT9B1Jl4>#Uh-Bec z`Hsp<==#UEW6pGPhNk8H!!DUQR~#F9jEMI6T*OWfN^Ze&X(4nV$wa8QUJ>oTkruH# zm~O<`J7Wxseo@FqaZMl#Y(mrFW9AHM9Kb|XBMqaZ2a)DvJgYipkDD_VUF_PKd~dT7 z#02}bBfPn9a!X!O#83=lbJSK#E}K&yx-HI#T6ua)6o0{|={*HFusCkHzs|Fn&|C3H zBck1cmfcWVUN&i>X$YU^Sn6k2H;r3zuXbJFz)r5~3$d$tUj(l1?o={MM){kjgqXRO zc5R*#{;V7AQh|G|)jLM@wGAK&rm2~@{Pewv#06pHbKn#wL0P6F1!^qw9g&cW3Z=9} zj)POhOlwsh@eF=>z?#sIs*C-Nl(yU!#DaiaxhEs#iJqQ8w%(?+6lU02MYSeDkr!B- zPjMv+on6OLXgGnAtl(ao>|X2Y8*Hb}GRW5}-IzXnoo-d0!m4Vy$GS!XOLy>3_+UGs z2D|YcQx@M#M|}TDOetGi{9lGo9m-=0-^+nKE^*?$^uHkxZh}I{#UTQd;X!L+W@jm( zDg@N4+lUqI92o_rNk{3P>1gxAL=&O;x)ZT=q1mk0kLlE$WeWuY_$0`0jY-Kkt zP*|m3AF}Ubd=`<>(Xg0har*_@x2YH}bn0Wk*OZz3*e5;Zc;2uBdnl8?&XjupbkOeNZsNh6pvsq_ydmJI+*z**{I{0K)-;p1~k8cpJXL$^t!-`E}=*4G^-E8>H!LjTPxSx zcF+cS`ommfKMhNSbas^@YbTpH1*RFrBuATUR zt{oFWSk^$xU&kbFQ;MCX22RAN5F6eq9UfR$ut`Jw--p2YX)A*J69m^!oYfj2y7NYcH6&r+0~_sH^c^nzeN1AU4Ga7=FlR{S|Mm~MpzY0$Z+p2W(a={b-pR9EO1Rs zB%KY|@wLcAA@)KXi!d2_BxrkhDn`DT1=Dec}V!okd{$+wK z4E{n8R*xKyci1(CnNdhf$Dp2(Jpof0-0%-38X=Dd9PQgT+w%Lshx9+loPS~MOm%ZT zt%2B2iL_KU_ita%N>xjB!#71_3=3c}o zgeW~^U_ZTJQ2!PqXulQd=3b=XOQhwATK$y(9$#1jOQ4}4?~l#&nek)H(04f(Sr=s| zWv7Lu1=%WGk4FSw^;;!8&YPM)pQDCY9DhU`hMty1@sq1=Tj7bFsOOBZOFlpR`W>-J$-(kezWJj;`?x-v>ev{*8V z8p|KXJPV$HyQr1A(9LVrM47u-XpcrIyO`yWvx1pVYc&?154aneRpLqgx)EMvRaa#|9?Wwqs2+W8n5~79G z(}iCiLk;?enn}ew`HzhG+tu+Ru@T+K5juvZN)wY;x6HjvqD!&!)$$;1VAh~7fg0K| zEha#aN=Yv|3^~YFH}cc38ovVb%L|g@9W6fo(JtT6$fa?zf@Ct88e}m?i)b*Jgc{fl zExfdvw-BYDmH6>(4QMt#p0;FUIQqkhD}aH?a7)_%JtA~soqj{ppP_82yi9kaxuK>~ ze_)Zt>1?q=ZH*kF{1iq9sr*tVuy=u>Zev}!gEZx@O6-fjyu9X00gpIl-fS_pzjpqJ z1yqBmf9NF!jaF<+YxgH6oXBdK)sH(>VZ)1siyA$P<#KDt;8NT*l_0{xit~5j1P)FN zI8hhYKhQ)i z37^aP13B~u65?sg+_@2Kr^iWHN=U;EDSZ@2W2!5ALhGNWXnFBY%7W?1 z=HI9JzQ-pLKZDYTv<0-lt|6c-RwhxZ)mU2Os{bsX_i^@*fKUj8*aDO5pks=qn3Dv6 zwggpKLuyRCTVPwmw1r}B#AS}?X7b837UlXwp~E2|PJw2SGVueL7){Y&z!jL!XN=0i zU^Eig`S2`{+gU$68aRdWx?BZ{sU_f=8sn~>s~M?GU~`fH5kCc; z8ICp+INM3(3{#k32RZdv6b9MQYdZXNuk7ed8;G?S2nT+NZBG=Tar^KFl2SvhW$bGW#kdWL-I)s_IqVnCDDM9fm8g;P;8 z7t4yZn3^*NQfx7SwmkzP$=fwdC}bafQSEF@pd&P8@H#`swGy_rz;Z?Ty5mkS%>m#% zp_!m9e<()sfKiY(nF<1zBz&&`ZlJf6QLvLhl`_``%RW&{+O>Xhp;lwSsyRqGf=RWd zpftiR`={2(siiPAS|p}@q=NhVc0ELprt%=fMXO3B)4ryC2LT(o=sLM7hJC!}T1@)E zA3^J$3&1*M6Xq>03FX`R&w*NkrZE?FwU+Muut;>qNhj@bX17ZJxnOlPSZ=Zeiz~T_ zOu#yc3t6ONHB;?|r4w+pI)~KGN;HOGC)txxiUN8#mexj+W(cz%9a4sx|IRG=}ia zuEBuba3AHsV2feqw-3MvuL`I+2|`Ud4~7ZkN=JZ;L20|Oxna5vx1qbIh#k2O4$RQF zo`tL()zxaqibg^GbB+BS5#U{@K;WWQj~GcB1zb}zJkPwH|5hZ9iH2308!>_;%msji zJHSL~s)YHBR=Koa1mLEOHos*`gp=s8KA-C zu0aE+W!#iJ*0xqKm3A`fUGy#O+X+5W36myS>Uh2!R*s$aCU^`K&KKLCCDkejX2p=5 z%o7-fl03x`gaSNyr?3_JLv?2RLS3F*8ub>Jd@^Cc17)v8vYEK4aqo?OS@W9mt%ITJ z9=S2%R8M){CugT@k~~0x`}Vl!svYqX=E)c_oU6o}#Hb^%G1l3BudxA{F*tbjG;W_>=xV73pKY53v%>I)@D36I_@&p$h|Aw zonQS`07z_F#@T-%@-Tb|)7;;anoD_WH>9ewFy(ZcEOM$#Y)8>qi7rCnsH9GO-_7zF zu*C87{Df1P4TEOsnzZ@H%&lvV(3V@;Q!%+OYRp`g05PjY^gL$^$-t0Y>H*CDDs?FZly*oZ&dxvsxaUWF!{em4{A>n@vpXg$dwvt@_rgmHF z-MER`ABa8R-t_H*kv>}CzOpz;!>p^^9ztHMsHL|SRnS<-y5Z*r(_}c4=fXF`l^-i}>e7v!qs_jv zqvWhX^F=2sDNWA9c@P0?lUlr6ecrTKM%pNQ^?*Lq?p-0~?_j50xV%^(+H>sMul#Tw zeciF*1=?a7cI(}352%>LO96pD+?9!fNyl^9v3^v&Y4L)mNGK0FN43&Xf8jUlxW1Bw zyiu2;qW-aGNhs=zbuoxnxiwZ3{PFZM#Kw)9H@(hgX23h(`Wm~m4&TvoZoYp{plb^> z_#?vXcxd>r7K+1HKJvhed>gtK`TAbJUazUWQY6T~t2af%#<+Veyr%7-#*A#@&*;@g58{i|E%6yC_InGXCOd{L0;$)z#?n7M`re zh!kO{6=>7I?*}czyF7_frt#)s1CFJ_XE&VrDA?Dp3XbvF{qsEJgb&OLSNz_5g?HpK z9)8rsr4JN!Af3G9!#Qn(6zaUDqLN(g2g8*M)Djap?WMK9NKlkC)E2|-g|#-rp%!Gz zAHd%`iq|81efi93m3yTBw3g0j#;Yb2X{mhRAI?&KDmbGqou(2xiRNb^sV}%%Wu0?< z?($L>(#BO*)^)rSgyNRni$i`R4v;GhlCZ8$@e^ROX(p=2_v6Y!%^As zu022)fHdv_-~Yu_H6WVPLpHQx!W%^6j)cBhS`O3QBW#x(eX54d&I22op(N59b*&$v zFiSRY6rOc^(dgSV1>a7-5C;(5S5MvKcM2Jm-LD9TGqDpP097%52V+0>Xqq!! zq4e3vj53SE6i8J`XcQB|MZPP8j;PAOnpGnllH6#Ku~vS42xP*Nz@~y%db7Xi8s09P z1)e%8ys6&M8D=Dt6&t`iKG_4X=!kgRQoh%Z`dc&mlOUqXk-k`jKv9@(a^2-Upw>?< zt5*^DV~6Zedbec4NVl($2T{&b)zA@b#dUyd>`2JC0=xa_fIm8{5um zr-!ApXZhC8@=vC2WyxO|!@0Km)h8ep*`^he92$@YwP>VcdoS5OC^s38e#7RPsg4j+ zbVGG}WRSET&ZfrcR(x~k8n1rTP%CnfUNKUonD$P?FtNFF#cn!wEIab-;jU=B1dHK@ z(;(yAQJ`O$sMn>h;pf^8{JISW%d+@v6@CnXh9n5TXGC}?FI9i-D0OMaIg&mAg=0Kn zNJ7oz5*ReJukD55fUsMuaP+H4tDN&V9zfqF@ zr=#ecUk9wu{0;!+gl;3Bw=Vn^)z$ahVhhw)io!na&9}LmWurLb0zubxK=UEnU*{5P z+SP}&*(iBKSO4{alBHaY^)5Q=mZ+2OwIooJ7*Q5XJ+2|q`9#f?6myq!&oz?klihLq z4C)$XP!BNS0G_Z1&TM>?Jk{S~{F3n83ioli=IO6f%wkvCl(RFFw~j0tb{GvXTx>*sB0McY0s&SNvj4+^h`9nJ_wM>F!Uc>X}9PifQekn0sKI2SAJP!a4h z5cyGTuCj3ZBM^&{dRelIlT^9zcfaAuL5Y~bl!ppSf`wZbK$z#6U~rdclk``e+!qhe z6Qspo*%<)eu6?C;Bp<^VuW6JI|Ncvyn+LlSl;Mp22Bl7ARQ0Xc24%29(ZrdsIPw&-=yHQ7_Vle|5h>AST0 zUGX2Zk34vp?U~IHT|;$U86T+UUHl_NE4m|}>E~6q``7hccCaT^#y+?wD##Q%HwPd8 zV3x4L4|qqu`B$4(LXqDJngNy-{&@aFBvVsywt@X^}iH7P%>bR?ciC$I^U-4Foa`YKI^qDyGK7k%E%c_P=yzAi`YnxGA%DeNd++j3*h^ z=rn>oBd0|~lZ<6YvmkKY*ZJlJ;Im0tqgWu&E92eqt;+NYdxx`eS(4Hw_Jb5|yVvBg z*tbdY^!AN;luEyN4VRhS@-_DC{({ziH{&Z}iGElSV~qvT>L-8G%+yEL zX#MFOhj{InyKG=mvW-<1B@c-}x$vA(nU?>S>0*eN#!SLzQ)Ex7fvQ)S4D<8|I#N$3 zT5Ei`Z?cxBODHX8(Xp73v`IsAYC@9b;t}z0wxVuQSY1J^GRwDPN@qbM-ZF48T$GZ< z8WU+;Pqo?{ghI-KZ-i*ydXu`Ep0Xw^McH_KE9J0S7G;x8Fe`DVG?j3Pv=0YzJ}yZR z%2=oqHiUjvuk0~Ca>Kol4CFi0_xQT~;_F?=u+!kIDl-9g`#ZNZ9HCy17Ga1v^Jv9# z{T4Kb1-AzUxq*MutfOWWZgD*HnFfyYg0&e9f(5tZ>krPF6{VikNeHoc{linPPt#Si z&*g>(c54V8rT_AX!J&bNm-!umPvOR}vDai#`CX___J#=zeB*{4<&2WpaDncZsOkp* zsg<%@@rbrMkR_ux9?LsQxzoBa1s%$BBn6vk#{&&zUwcfzeCBJUwFYSF$08qDsB;gWQN*g!p8pxjofWbqNSZOEKOaTx@+* zwdt5*Q47@EOZ~EZL9s?1o?A%9TJT=Ob_13yyugvPg*e&ZU(r6^k4=2+D-@n=Hv5vu zSXG|hM(>h9^zn=eQ=$6`JO&70&2|%V5Lsx>)(%#;pcOfu>*nk_3HB_BNaH$`jM<^S zcSftDU1?nL;jy)+sfonQN}(}gUW?d_ikr*3=^{G)=tjBtEPe>TO|0ddVB zTklrSHiW+!#26frPXQQ(YN8DG$PZo?(po(QUCCf_OJC`pw*uey00%gmH!`WJkrKXj2!#6?`T25mTu9OJp2L8z3! z=arrL$ZqxuE{%yV)14Kd>k}j7pxZ6#$Dz8$@WV5p8kTqN<-7W)Q7Gt2{KoOPK_tZ| zf2WG~O5@{qPI+W<4f_;reuFVdO^5`ADC1!JQE|N`s3cq@(0WB!n0uh@*c{=LAd;~} zyGK@hbF-Oo+!nN)@i*O(`@FA#u?o=~e{`4O#5}z&=UkU*50fOrzi11D^&FOqe>wii z?*k+2|EcUs;Gx{!@KBT~>PAwLrIDT7Th=Utu?~?np@t^gFs?zgX=D${RwOY^WGh-+ z+#4$066ISh8eYW#FXWp~S`<*%O^ZuItL1Tyqt8#tZ zY120E;^VG`!lZn&3sPd$RkdHpU#|w+bYV)pJC|SH9g%|5IkxVTQcBA4CL0}$&}ef@ zW^Vtj%M;;_1xxP9x#ex17&4N*{ksO*_4O}xYu(p*JkL#yr}@7b)t5X?%CY<+s5_MJ zuiqt+N_;A(_)%lumoyRFixWa-M7qK_9s6<1X?JDa9fP!+_6u~~M$5L=ipB=7(j#f< zZ34J%=bs549%~_mA(|={uZNs_0?o7;-LBP(ZRnkd{-^|2|=4vUTmtByHL8 zEph`(LSEzQj68a+`d$V<45J7cyv^#|^|%fD#si1Nx!4NW*`l*{->HEWNh6-|g>-=r zXmQ|-i}Ku$ndUeHQ^&ieT!Lf}vf6GaqW9$DJ2NWrqwPY%%4nip$@vK$nRp*_C-v<| zuKz~ZyN&<%!NS26&x?jhy+@awJipMQ-8(X4#Ae5??U<1QMt1l9R=w9fAnEF}NYu$2 z>6}Vkc zIb*A?G*z8^IvibmBKn_u^5&T_1oey0gZS2~obf(#xk=erZGTEdQnt3DMGM+0oPwss zj5zXD;(oWhB_T@~Ig#9@v)AKtXu3>Inmgf@A|-lD-1U>cNyl3h?ADD9)GG4}zUGPk zZzaXe!~Kf?<~@$G?Uql3t8jy9{2!doq4=J}j9ktTxss{p6!9UdjyDERlA*xZ!=Q)KDs5O)phz>Vq3BNGoM(H|=1*Q4$^2fTZw z(%nq1P|5Rt81}SYJpEEzMPl5VJsV5&4e)ZWKDyoZ>1EwpkHx-AQVQc8%JMz;{H~p{=FXV>jIxvm4X*qv52e?Y-f%DJ zxEA165GikEASQ^fH6K#d!Tpu2HP{sFs%E=e$gYd$aj$+xue6N+Wc(rAz~wUsk2`(b z8Kvmyz%bKQxpP}~baG-rwYcYCvkHOi zlkR<=>ZBTU*8RF_d#Bl@zZsRIhx<%~Z@Z=ik z>adw3!DK(8R|q$vy{FTxw%#xliD~6qXmY^7_9kthVPTF~Xy1CfBqbU~?1QmxmU=+k z(ggxvEuA;0e&+ci-zQR{-f7aO{O(Pz_OsEjLh_K>MbvoZ4nxtk5u{g@nPv)cgW_R} z9}EA4K4@z0?7ue}Z(o~R(X&FjejUI2g~08PH1E4w>9o{)S(?1>Z0XMvTb|;&EuyOE zGvWNpYX)Nv<8|a^;1>bh#&znEcl-r!T#pn= z4$?Yudha6F%4b>*8@=BdtXXY4N+`U4Dmx$}>HeVJk-QdTG@t!tVT#0(LeV0gvqyyw z2sEp^9eY0N`u10Tm4n8No&A=)IeEC|gnmEXoNSzu!1<4R<%-9kY_8~5Ej?zRegMn78wuMs#;i&eUA0Zk_RXQ3b&TT} z;SCI=7-FUB@*&;8|n>(_g^HGf3@QODE3LpmX~ELnymQm{Sx9xrKS zK29p~?v@R$0=v6Dr5aW>-!{+h@?Q58|Kz8{{W`%J+lDAdb&M5VHrX_mDY;1-JLnf)ezmPau$)1;=`-FU=-r-83tX=C`S#}GZufju zQ>sXNT0Ny=k@nc%cFnvA_i4SC)?_ORXHq8B4D%el1uPX`c~uG#S1M7C+*MMqLw78E zhY2dI8@+N^qrMI1+;TUda(vGqGSRyU{Fnm`aqrr7bz42c5xsOO-~oZpkzorD1g}Y<6rk&3>PsSGy}W?MtqFky@A(X# zIuNZK0cK?^=;PUAu>j0#HtjbHCV*6?jzA&OoE$*Jlga*}LF`SF?WLhv1O|zqC<>*> zYB;#lsYKx0&kH@BFpW8n*yDcc6?;_zaJs<-jPSkCsSX-!aV=P5kUgF@Nu<{a%#K*F z134Q{9|YX7X(v$62_cY3^G%t~rD>Q0z@)1|zs)vjJ6Jq9;7#Ki`w+eS**En?7;n&7 zu==V3T&eFboN3ZiMx3D8qYc;VjFUk_H-WWCau(VFXSQf~viH0L$gwD$UfFHqNcgN`x}M+YQ6RnN<+@t>JUp#)9YOkqst-Ga?{FsDpEeX0(5v{0J~SEbWiL zXC2}M4?UH@u&|;%0y`eb33ldo4~z-x8zY!oVmV=c+f$m?RfDC35mdQ2E>Pze7KWP- z>!Bh<&57I+O_^s}9Tg^k)h7{xx@0a0IA~GAOt2yy!X%Q$1rt~LbTB6@Du!_0%HV>N zlf)QI1&gvERKwso23mJ!Ou6ZS#zCS5W`gxE5T>C#E|{i<1D35C222I33?Njaz`On7 zi<+VWFP6D{e-{yiN#M|Jgk<44u1TiMI78S5W`Sdb5f+{zu34s{CfWN7a3Cf^@L%!& zN$?|!!9j2c)j$~+R6n#891w-z8(!oBpL2K=+%a$r2|~8-(vQj5_XT`<0Ksf;oP+tz z9CObS!0m)Tgg`K#xBM8B(|Z)Wb&DYL{WTYv`;A=q6~Nnx2+!lTIXtj8J7dZE!P_{z z#f8w6F}^!?^KE#+ZDv+xd5O&3EmomZzsv?>E-~ygGum45fk!SBN&|eo1rKw^?aZJ4 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..17655d0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..5cf02f0 --- /dev/null +++ b/gradlew @@ -0,0 +1,190 @@ +#!/usr/bin/env sh + +# Need Java 21, and this seems to be the easiest way to do that per-branch +if [ -n "$IS_CI" ]; then + JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 +fi + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gui/elementa/build.gradle.kts b/gui/elementa/build.gradle.kts new file mode 100644 index 0000000..142e0d1 --- /dev/null +++ b/gui/elementa/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.universalLibs +import gg.essential.gradle.util.KotlinVersion +import gg.essential.gradle.util.setJvmDefault + +plugins { + kotlin("jvm") + id("gg.essential.defaults") +} + +universalLibs() + +dependencies { + implementation(kotlin("stdlib-jdk8", KotlinVersion.minimal.stdlib)) + implementation(project(":feature-flags")) + api(project(":elementa:layoutdsl")) +} + +// We need to use the compatibility mode on old versions because we used to use the old Kotlin defaults for those +// And while this isn't currently part of our ABI, once stuff migrates to Elementa, it will be, so we consider it now. +tasks.compileKotlin.setJvmDefault("all-compatibility") + +kotlin.jvmToolchain(8) + +tasks.compileKotlin { + kotlinOptions { + moduleName = "essential" + project.path.replace(':', '-').lowercase() + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/common/WeakState.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/common/WeakState.kt new file mode 100644 index 0000000..9c7011d --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/common/WeakState.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference +import java.util.* +import java.util.function.Consumer + +/** + * A state which allows subscribing to an inner state without creating a strong reference from it. + * + * This allows short-lived states (e.g. elements in a dynamic list) to subscribe to longer-lived states (e.g. stored in + * the screen or globally) while still being garbage-collectible, even before the longer-lived state is not. + * + * Note that the other way round, this is not true. This instance does keep a strong reference to its inner state, so + * it can safely depend on other weak states. + */ +@Deprecated("Using StateV1 is discouraged, use StateV2 instead which is weak by default") +class WeakState(private val inner: State) : BasicState(inner.get()) { + init { + // Get the reference queue for the given inner state, so we can clean up any stale listeners registered on it + val referenceQueue = referenceQueues.getOrPut(inner, ::ReferenceQueue) + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as WeakListener<*>).unregister() + } + + // Create a listener which only carries a weak reference to this state + val listener = WeakListener(this, referenceQueue) + // and register it on the inner state + listener.unregister = inner.onSetValue(listener) + } + + override fun get(): T = inner.get() + + /** + * A Consumer which forwards calls to the given state while that is still alive. The listener itself only keeps a + * weak reference to the outer state, and as such does not keep it alive just by being registered. + */ + private class WeakListener( + weakState: WeakState, + referenceQueue: ReferenceQueue>, + ) : WeakReference>(weakState, referenceQueue), Consumer { + lateinit var unregister: () -> Unit + + override fun accept(value: T) { + get()?.set(value) + } + } + + private companion object { + /** + * To clean up stale listeners from the inner state's listeners list, we maintain a reference queue for each + * inner state. + * Whenever we get asked to add a new listener to an inner state, we can then poll the queue for stale + * references and clean them up. This way, stale references cannot accumulate on inner states. + * Ideally this would happen in the onSetValue method of the inner state but since we can't change that, a weak + * hash map is the next best thing. + * + * We need to keep one reference queue per inner state because the unregister method is not thread safe and the + * safe thread to call it may be different for each inner state. + */ + val referenceQueues = WeakHashMap, ReferenceQueue>>() + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/common/extensions.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/common/extensions.kt new file mode 100644 index 0000000..a8add32 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/common/extensions.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.UIConstraints +import gg.essential.elementa.components.ScrollComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.dsl.effect +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.utils.ObservableAddEvent +import gg.essential.elementa.utils.ObservableClearEvent +import gg.essential.elementa.utils.ObservableList +import gg.essential.elementa.utils.ObservableRemoveEvent +import gg.essential.gui.elementa.state.v2.toV1 +import gg.essential.gui.util.hasWindow +import kotlin.reflect.KProperty + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun State.weak() = WeakState(this) + +fun T.bindConstraints(state: State, config: UIConstraints.(S) -> Unit) = apply { + state.onSetValueAndNow { + constraints.config(it) + } +} + +fun T.bindConstraints(state: gg.essential.gui.elementa.state.v2.State, config: UIConstraints.(S) -> Unit) = apply { + constraints.config(state.get()) + state.onSetValue(this) { + constraints.config(it) + } +} + +fun T.bindParent( + parent: UIComponent, + state: State, + delayed: Boolean = false, + index: Int? = null +) = + bindParent(state.map { + if (it) parent else null + }, delayed, index) + +fun T.bindParent( + parent: UIComponent, + state: gg.essential.gui.elementa.state.v2.State, + delayed: Boolean = false, + index: Int? = null +) = bindParent(parent, state.toV1(parent), delayed, index) + +fun T.bindFloating(state: State) = apply { + state.onSetValueAndNow { + if (hasWindow) { + this.setFloating(it) + } + } +} + +fun T.bindEffect(effect: Effect, state: State, delayed: Boolean = true) = apply { + state.onSetValueAndNow { + val update = { + if (it) { + this.effect(effect) + } else { + this.removeEffect(effect) + } + } + if (delayed) { + Window.enqueueRenderOperation { + update() + } + } else { + update() + } + } +} + +fun T.bindEffect(effect: Effect, state: gg.essential.gui.elementa.state.v2.State, delayed: Boolean = true) = + bindEffect(effect, state.toV1(this), delayed) + +fun T.bindParent(state: State, delayed: Boolean = false, index: Int? = null) = apply { + state.onSetValueAndNow { parent -> + val handleStateUpdate = { + if (this.hasParent && this.parent != parent) { + this.parent.removeChild(this) + } + if (parent != null && this !in parent.children) { + if (index != null) { + parent.insertChildAt(this, index) + } else { + parent.addChild(this) + } + } + } + if (delayed) { + Window.enqueueRenderOperation { + handleStateUpdate() + } + } else { + handleStateUpdate() + } + } +} + +fun State.empty() = map { it.isBlank() } +infix fun State.and(other: State) = zip(other).map { (a, b) -> a && b } +infix fun State.or(other: State) = zip(other).map { (a, b) -> a || b } + +class ReadOnlyState(private val internalState: State) : State() { + + init { + internalState.onSetValueAndNow { + super.set(it) + } + } + + override fun get(): T { + return internalState.get() + } + + @Suppress("DeprecatedCallableAddReplaceWith") + @Deprecated("This state is read-only", level = DeprecationLevel.ERROR) + override fun set(value: T) { + throw IllegalStateException("Cannot set read only value") + } +} + +operator fun State.getValue(obj: Any, property: KProperty<*>): T = get() +operator fun State.setValue(obj: Any, property: KProperty<*>, value: T) = set(value) + +fun State.mapToString() = this.map { it.toString() } + +fun T.state() = BasicState(this) + +fun State.layoutSafe(): State { + val safeState = BasicState(get()) + onSetValue { Window.enqueueRenderOperation { safeState.set(it) } } + return safeState +} + + +fun T.bindChildren( + list: ObservableList, + filter: (E) -> Boolean = { true }, + comparator: Comparator? = null, + mapper: (E) -> UIComponent, +): T { + + val components = mutableListOf() + + fun sort() { + if (comparator != null) { + if (this is ScrollComponent) { + this.sortChildren(comparator) + } else { + children.sortWith(comparator) + } + } + } + + fun handleNewItem(item: E, index: Int, delayed: Boolean) { + if (!filter(item)) { + components.add(index, null) + return + } + val element = mapper(item) + components.add(index, element) + + val addAndSort = { + addChild(element) + sort() + } + if (delayed) { + Window.enqueueRenderOperation(addAndSort) + } else { + addAndSort() + } + } + + fun removeItem(index: Int) { + components.removeAt(index)?.let { + Window.enqueueRenderOperation { + removeChild(it) + sort() + } + } + } + + list.addObserver { _, arg -> + when (arg) { + is ObservableRemoveEvent<*> -> { + removeItem(arg.element.index) + } + is ObservableAddEvent<*> -> { + handleNewItem(arg.element.value as E, arg.element.index, true) + } + is ObservableClearEvent<*> -> { + components.indices.reversed().forEach { + removeItem(it) + } + } + } + } + list.forEachIndexed { index, e -> handleNewItem(e, index, false) } + + return this +} + +/** + * Maps an observable list to a new observable list + * of type [V] using the given [mapper] function. + * + */ +fun ObservableList.map( + mapper: (E) -> V, +): ObservableList { + + val result = ObservableList(mutableListOf()) + + addObserver { _, arg -> + when (arg) { + is ObservableRemoveEvent<*> -> { + result.removeAt(arg.element.index) + } + + is ObservableAddEvent<*> -> { + result.add(arg.element.index, mapper(arg.element.value as E)) + } + + is ObservableClearEvent<*> -> { + result.clear() + } + } + } + forEach { + result.add(mapper(it)) + } + + return result +} +private val updateCount = { a: Int, b: Int -> (a + b).takeIf { it > 0 } } diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/GuiScaleOffsetConstraint.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/GuiScaleOffsetConstraint.kt new file mode 100644 index 0000000..95515a4 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/GuiScaleOffsetConstraint.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.MasterConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import gg.essential.universal.UResolution + +class GuiScaleOffsetConstraint(val offset: Float = -1f) : MasterConstraint { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + private fun getValue(): Float { + val scaleFactor = UResolution.scaleFactor + return ((scaleFactor + offset).coerceAtLeast(1.0) / scaleFactor).toFloat() + } + + override fun getXPositionImpl(component: UIComponent): Float = component.parent.getLeft() + getValue() + + override fun getYPositionImpl(component: UIComponent): Float = component.parent.getTop() + getValue() + + override fun getWidthImpl(component: UIComponent): Float = getValue() + + override fun getHeightImpl(component: UIComponent): Float = getValue() + + override fun getRadiusImpl(component: UIComponent): Float = getValue() + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + } +} \ No newline at end of file diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/LazyConstraint.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/LazyConstraint.kt new file mode 100644 index 0000000..86112c2 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/LazyConstraint.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.HeightConstraint +import gg.essential.elementa.constraints.MasterConstraint +import gg.essential.elementa.constraints.PositionConstraint +import gg.essential.elementa.constraints.RadiusConstraint +import gg.essential.elementa.constraints.SuperConstraint +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.constraints.XConstraint +import gg.essential.elementa.constraints.YConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor + +fun lazyPosition(initializer: () -> PositionConstraint) = LazyConstraint(lazy(initializer)) as PositionConstraint + +fun lazyHeight(initializer: () -> HeightConstraint) = LazyConstraint(lazy(initializer)) as HeightConstraint + +private class LazyConstraint(val constraint: Lazy>): MasterConstraint { + + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun animationFrame() { + super.animationFrame() + constraint.value.animationFrame() + } + + override fun getHeightImpl(component: UIComponent): Float { + return (constraint.value as HeightConstraint).getHeightImpl(component) + } + + override fun getRadiusImpl(component: UIComponent): Float { + return (constraint.value as RadiusConstraint).getRadiusImpl(component) + } + + override fun getWidthImpl(component: UIComponent): Float { + return (constraint.value as WidthConstraint).getWidthImpl(component) + } + + override fun getXPositionImpl(component: UIComponent): Float { + return (constraint.value as XConstraint).getXPositionImpl(component) + } + + override fun getYPositionImpl(component: UIComponent): Float { + return (constraint.value as YConstraint).getYPositionImpl(component) + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {} + +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/PredicatedHitTestContainer.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/PredicatedHitTestContainer.kt new file mode 100644 index 0000000..ab0fee8 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/PredicatedHitTestContainer.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIContainer + +class PredicatedHitTestContainer : UIContainer() { + var shouldIgnore: (UIComponent) -> Boolean = { false } + + override fun hitTest(x: Float, y: Float): UIComponent { + for (i in children.lastIndex downTo 0) { + val child = children[i] + + if (child.isPointInside(x, y)) { + val value = child.hitTest(x, y) + + if (shouldIgnore(value)) { + continue + } + + return value + } + } + + return this + } +} \ No newline at end of file diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/AlphaEffect.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/AlphaEffect.kt new file mode 100644 index 0000000..606f94e --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/AlphaEffect.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.effects + +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.State +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.UResolution +import gg.essential.universal.shader.BlendState +import gg.essential.universal.shader.SamplerUniform +import gg.essential.universal.shader.UShader +import gg.essential.util.GuiElementaPlatform.Companion.platform +import org.lwjgl.opengl.GL11 +import java.io.Closeable +import java.lang.ref.PhantomReference +import java.lang.ref.ReferenceQueue +import java.nio.ByteBuffer +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Applies an alpha value to a component. This is done by snapshotting the framebuffer behind the component, + * rendering the component, then rendering the snapshot with the inverse of the desired alpha. + */ +class AlphaEffect(private val alphaState: State) : Effect() { + private val resources = Resources(this) + private var textureWidth = -1 + private var textureHeight = -1 + + override fun setup() { + initShader() + Resources.drainCleanupQueue() + resources.textureId = GL11.glGenTextures() + } + + override fun beforeDraw(matrixStack: UMatrixStack) { + if (resources.textureId == -1) error("AlphaEffect has not yet been setup or has already been cleaned up! ElementaVersion.V4 or newer is required for proper operation!") + + val scale = UResolution.scaleFactor + + // Get the coordinates of the component within the bounds of the screen in real pixels + val left = (boundComponent.getLeft() * scale).toInt().coerceIn(0..UResolution.viewportWidth) + val right = (boundComponent.getRight() * scale).toInt().coerceIn(0..UResolution.viewportWidth) + val top = (boundComponent.getTop() * scale).toInt().coerceIn(0..UResolution.viewportHeight) + val bottom = (boundComponent.getBottom() * scale).toInt().coerceIn(0..UResolution.viewportHeight) + + val x = left + val y = UResolution.viewportHeight - bottom // OpenGL screen coordinates start in the bottom left + val width = right - left + val height = bottom - top + + if (width == 0 || height == 0 || !shader.usable) { + return + } + + UGraphics.configureTexture(resources.textureId) { + if (width != textureWidth || height != textureHeight) { + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, width, height, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, null as ByteBuffer?) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST) + textureWidth = width + textureHeight = height + } + + GL11.glCopyTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, x, y, width, height) + } + } + + override fun afterDraw(matrixStack: UMatrixStack) { + // Get the coordinates of the component within the bounds of the screen in fractional MC pixels + val left = boundComponent.getLeft().toDouble().coerceIn(0.0..UResolution.viewportWidth / UResolution.scaleFactor) + val right = boundComponent.getRight().toDouble().coerceIn(0.0..UResolution.viewportWidth / UResolution.scaleFactor) + val top = boundComponent.getTop().toDouble().coerceIn(0.0..UResolution.viewportHeight / UResolution.scaleFactor) + val bottom = boundComponent.getBottom().toDouble().coerceIn(0.0..UResolution.viewportHeight / UResolution.scaleFactor) + + val x = left + val y = top + val width = right - left + val height = bottom - top + + if (width == 0.0 || height == 0.0 || !shader.usable) { + return + } + + val red = 1f + val green = 1f + val blue = 1f + val alpha = 1f - alphaState.get() + + var prevAlphaTestFunc = 0 + var prevAlphaTestRef = 0f + if (!platform.isCoreProfile) { + prevAlphaTestFunc = GL11.glGetInteger(GL11.GL_ALPHA_TEST_FUNC) + prevAlphaTestRef = GL11.glGetFloat(GL11.GL_ALPHA_TEST_REF) + platform.glAlphaFunc(GL11.GL_ALWAYS, 0f) + } + + shader.bind() + textureUniform.setValue(resources.textureId) + + val worldRenderer = UGraphics.getFromTessellator() + worldRenderer.beginWithActiveShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) + worldRenderer.pos(matrixStack, x, y + height, 0.0).tex(0.0, 0.0).color(red, green, blue, alpha).endVertex() + worldRenderer.pos(matrixStack, x + width, y + height, 0.0).tex(1.0, 0.0).color(red, green, blue, alpha).endVertex() + worldRenderer.pos(matrixStack, x + width, y, 0.0).tex(1.0, 1.0).color(red, green, blue, alpha).endVertex() + worldRenderer.pos(matrixStack, x, y, 0.0).tex(0.0, 1.0).color(red, green, blue, alpha).endVertex() + worldRenderer.drawDirect() + + shader.unbind() + + if (!platform.isCoreProfile) { + platform.glAlphaFunc(prevAlphaTestFunc, prevAlphaTestRef) + } + } + + fun cleanup() { + resources.close() + } + + private class Resources(effect: AlphaEffect) : PhantomReference(effect, referenceQueue), Closeable { + var textureId = -1 + + init { + toBeCleanedUp.add(this) + } + + override fun close() { + toBeCleanedUp.remove(this) + + if (textureId != -1) { + GL11.glDeleteTextures(textureId) + textureId = -1 + } + } + + companion object { + val referenceQueue = ReferenceQueue() + val toBeCleanedUp: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) + + fun drainCleanupQueue() { + while (true) { + ((referenceQueue.poll() ?: break) as Resources).close() + } + } + } + } + + companion object { + private lateinit var shader: UShader + private lateinit var textureUniform: SamplerUniform + + private fun initShader() { + if (::shader.isInitialized) return + + shader = UShader.fromLegacyShader(""" + #version 110 + + varying vec2 f_Position; + varying vec2 f_TexCoord; + + void main() { + f_Position = gl_Vertex.xy; + f_TexCoord = gl_MultiTexCoord0.st; + + gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; + gl_FrontColor = gl_Color; + } + """.trimIndent(), """ + #version 110 + + uniform sampler2D u_Texture; + + varying vec2 f_Position; + varying vec2 f_TexCoord; + + void main() { + gl_FragColor = gl_Color * vec4(texture2D(u_Texture, f_TexCoord).rgb, 1.0); + } + """.trimIndent(), BlendState.NORMAL, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) + + if (!shader.usable) { + println("Failed to load AlphaEffect shader") + return + } + + textureUniform = shader.getSamplerUniform("u_Texture") + } + } +} \ No newline at end of file diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/GradientEffect.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/GradientEffect.kt new file mode 100644 index 0000000..9979660 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/GradientEffect.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.effects + +import gg.essential.elementa.effects.Effect +import gg.essential.gui.elementa.state.v2.State +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.shader.BlendState +import gg.essential.universal.shader.UShader +import org.intellij.lang.annotations.Language +import org.lwjgl.opengl.GL11 +import java.awt.Color + +/** + * Draws a gradient (smooth color transition) behind the bound component. + * + * Unlike [gg.essential.elementa.components.GradientComponent], this effect also applies dithering to the gradient to + * mitigate color banding artifacts. + * + * Note: The behavior of non-axis-aligned gradients (e.g. more than two colors, or diagonal) is currently undefined. + */ +class GradientEffect( + private val topLeft: State, + private val topRight: State, + private val bottomLeft: State, + private val bottomRight: State, +) : Effect() { + override fun beforeChildrenDraw(matrixStack: UMatrixStack) { + val topLeft = this.topLeft.get() + val topRight = this.topRight.get() + val bottomLeft = this.bottomLeft.get() + val bottomRight = this.bottomRight.get() + + val dither = topLeft != topRight || topLeft != bottomLeft || bottomLeft != bottomRight + if (dither) { + shader.bind() + } + + val buffer = UGraphics.getFromTessellator() + if (dither) { + buffer.beginWithActiveShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_COLOR) + } else { + buffer.beginWithDefaultShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_COLOR) + } + + val x1 = boundComponent.getLeft().toDouble() + val x2 = boundComponent.getRight().toDouble() + val y1 = boundComponent.getTop().toDouble() + val y2 = boundComponent.getBottom().toDouble() + + buffer.pos(matrixStack, x2, y1, 0.0).color(topRight).endVertex() + buffer.pos(matrixStack, x1, y1, 0.0).color(topLeft).endVertex() + buffer.pos(matrixStack, x1, y2, 0.0).color(bottomLeft).endVertex() + buffer.pos(matrixStack, x2, y2, 0.0).color(bottomRight).endVertex() + + // See UIBlock.drawBlock for why we use this depth function + UGraphics.enableDepth() + UGraphics.depthFunc(GL11.GL_ALWAYS) + buffer.drawDirect() + UGraphics.disableDepth() + UGraphics.depthFunc(GL11.GL_LEQUAL) + + if (dither) { + shader.unbind() + } + } + + companion object { + @Language("GLSL") + private val vertSource = """ + varying vec4 vColor; + + void main() { + gl_Position = gl_ProjectionMatrix * gl_ModelViewMatrix * gl_Vertex; + vColor = gl_Color; + } + """.trimIndent() + + @Language("GLSL") + private val fragSource = """ + varying vec4 vColor; + + void main() { + // Generate four pseudo-random values in range [-0.5; 0.5] for the current fragment coords, based on + // Vlachos 2016, "Advanced VR Rendering" + vec4 noise = vec4(dot(vec2(171.0, 231.0), gl_FragCoord.xy)); + noise = fract(noise / vec4(103.0, 71.0, 97.0, 127.0)) - 0.5; + + // Apply dithering, i.e. randomly offset all the values within a color band, so there are no harsh + // edges between different bands after quantization. + gl_FragColor = vColor + noise / 255.0; + } + """.trimIndent() + + private val shader: UShader by lazy { + UShader.fromLegacyShader(vertSource, fragSource, BlendState.NORMAL, UGraphics.CommonVertexFormats.POSITION_COLOR) + } + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/ZIndexEffect.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/ZIndexEffect.kt new file mode 100644 index 0000000..b4288a8 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/effects/ZIndexEffect.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.effects + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.effects.ScissorEffect +import gg.essential.universal.UMatrixStack + +class ZIndexEffect(val index: Int, val parent: UIComponent? = null) : Effect() { + private val scissor = ScissorEffect(0, 0, 0, 0, false) + private var passThrough: Boolean = false + + override fun beforeDraw(matrixStack: UMatrixStack) { + if (!passThrough) { + scissor.beforeDraw(matrixStack) + } + } + + override fun afterDraw(matrixStack: UMatrixStack) { + if (!passThrough) { + scissor.afterDraw(matrixStack) + + val parent = parent ?: boundComponent.parent + val coordinator = parent.effects.firstNotNullOfOrNull { it as? Coordinator } + ?: Coordinator().also { parent.enableEffect(it) } + coordinator.toBeDrawn.add(this) + } + } + + private class Coordinator : Effect() { + val toBeDrawn = mutableListOf() + + override fun afterDraw(matrixStack: UMatrixStack) { + toBeDrawn.sortBy { it.index } + toBeDrawn.forEach { effect -> + effect.passThrough = true + effect.boundComponent.draw(matrixStack) + effect.passThrough = false + } + toBeDrawn.clear() + + super.afterDraw(matrixStack) + } + } +} \ No newline at end of file diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/DrawState.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/DrawState.kt new file mode 100644 index 0000000..1d53dde --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/DrawState.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown + +/** + * Stores universal state necessary for rendering all drawables. + * + * The first time we layout the markdown tree (in MarkdownComponent), + * we lay everything out based upon the current x and y values of + * the MarkdownComponent. At this time, however, we do not yet know + * the height of the component (we need to layout all of the drawables + * to determine their height first). After this first layout, we set + * the height of the MarkdownComponent based on the heights and + * positions of the drawables. + * + * When we set this height, the y location of the MarkdownComponent + * may change (for example, if its y constraint is a CenterConstraint). + * So when we change the height, we would have to re-rendering the + * entire tree again to update the positions. It is possible that this + * may actually be a never-ending process (i.e. changing the height + * changes the position, and changing the position changes the height). + * + * To avoid all of the re-laying-out, we layout the first time and use + * those initial position values as a "base position" for all of the + * drawables. When MarkdownComponent#draw is called, it subtracts its + * current position from those base positions, stores those in this + * class as shift components, and passes that to all of the drawables. + * Those drawables then offset any drawing they do by these shift + * values. + */ +data class DrawState( + val xShift: Float, + val yShift: Float +) diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/EssentialMarkdown.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/EssentialMarkdown.kt new file mode 100644 index 0000000..0f7cb79 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/EssentialMarkdown.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.HeightConstraint +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.events.UIEvent +import gg.essential.gui.elementa.essentialmarkdown.drawables.BlockquoteDrawable +import gg.essential.gui.elementa.essentialmarkdown.drawables.DrawableList +import gg.essential.gui.elementa.essentialmarkdown.drawables.HeaderDrawable +import gg.essential.gui.elementa.essentialmarkdown.drawables.ListDrawable +import gg.essential.gui.elementa.essentialmarkdown.drawables.ParagraphDrawable +import gg.essential.gui.elementa.essentialmarkdown.selection.Cursor +import gg.essential.gui.elementa.essentialmarkdown.selection.Selection +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.mutableStateOf +import gg.essential.gui.elementa.state.v2.stateOf +import gg.essential.universal.UDesktop +import gg.essential.universal.UKeyboard +import gg.essential.universal.UMatrixStack +import gg.essential.vigilance.utils.onLeftClick +import org.apache.logging.log4j.LogManager +import java.awt.Color + +/** + * Component that parses a string as Markdown and renders it. + * + * This component's width and height must be non-child-related + * constraints. This is because the actual text rendering is + * done with direct render calls instead of through the component + * hierarchy. + */ +class EssentialMarkdown( + private val text: State, + config: MarkdownConfig = MarkdownConfig(), + private val disableSelection: Boolean = false, +) : UIComponent() { + + @JvmOverloads + constructor( + text: String, + config: MarkdownConfig = MarkdownConfig(), + disableSelection: Boolean = false, + ) : this(stateOf(text), config, disableSelection) + + private val configState = mutableStateOf(config) + val config: MarkdownConfig + get() = configState.get() + + val drawables = DrawableList(this, emptyList()) + var sectionOffsets: Map = emptyMap() + private set + private var baseX: Float = -1f + private var baseY: Float = -1f + private lateinit var lastValues: ConstraintValues + private var maxHeight: HeightConstraint = Int.MAX_VALUE.pixels() + private var cursor: Cursor<*>? = null + private var selection: Selection? = null + private var canDrag = false + private var needsInitialLayout = true + private val linkClickListeners = mutableListOf Unit>() + + var maxTextLineWidth = 0f + private set + + private var layoutFailed = false + + init { + onLeftClick { + val xShift = getLeft() - baseX + val yShift = getTop() - baseY + cursor = + drawables.cursorAt(it.absoluteX - xShift, it.absoluteY - yShift, dragged = false, it.mouseButton) + + selection?.remove() + selection = null + releaseWindowFocus() + } + if (!disableSelection) { + onMouseClick { + canDrag = true + } + + onMouseRelease { + canDrag = false + } + + onMouseDrag { mouseX, mouseY, mouseButton -> + if (mouseButton != 0 || !canDrag) + return@onMouseDrag + + val x = baseX + mouseX.coerceIn(0f, getWidth()) + val y = baseY + mouseY.coerceIn(0f, getHeight()) + + val otherEnd = drawables.cursorAt(x, y, dragged = true, mouseButton) + + if (cursor == otherEnd) + return@onMouseDrag + + selection?.remove() + selection = Selection.fromCursors(cursor!!, otherEnd) + grabWindowFocus() + } + + onKeyType { _, keyCode -> + if (selection != null && keyCode == UKeyboard.KEY_C && UKeyboard.isCtrlKeyDown()) { + UDesktop.setClipboardString(drawables.selectedText(UKeyboard.isShiftKeyDown())) + } + } + } + configState.onSetValue(this) { + reparse() + layout() + } + text.onSetValue(this) { + reparse() + layout() + } + } + + fun setMaxHeight(maxHeight: HeightConstraint) = apply { + this.maxHeight = maxHeight + } + + /** + * Parses the text into a markdown tree. This is called everytime + * that the text of this component changes, and is always followed + * by a call to layout(). + */ + private fun reparse() { + drawables.setDrawables(MarkdownRenderer(text.get(), this, config).render()) + } + + /** + * This method is responsible for laying out the markdown tree. + * + * @see Drawable.layout + */ + fun layout() { + try { + layoutFailed = false + + baseX = getLeft() + baseY = getTop() + var currY = baseY + val width = getWidth() + + drawables.forEach { + currY += it.layout(baseX, currY, width).height + } + + sectionOffsets = drawables.filterIsInstance().associate { it.id to it.y } + + setHeight((currY - baseY).coerceAtMost(maxHeight.getHeight(this)).pixels()) + + maxTextLineWidth = drawables.maxOfOrNull { drawable -> + when (drawable) { + is ParagraphDrawable -> drawable.maxTextLineWidth + is HeaderDrawable -> drawable.children.filterIsInstance().maxOfOrNull { it.maxTextLineWidth } ?: 0f + is ListDrawable -> drawable.maxTextLineWidth + is BlockquoteDrawable -> drawable.maxTextLineWidth + else -> 0f + } + } ?: 0f + } catch (e: Exception) { + LogManager.getLogger().error("Failed to layout markdown", e) + layoutFailed = true + } + } + + override fun animationFrame() { + super.animationFrame() + + if (needsInitialLayout) { + needsInitialLayout = false + reparse() + layout() + lastValues = constraintValues() + } + + // Re-layout if important constraint values have changed + val currentValues = constraintValues() + if (currentValues != lastValues) + layout() + lastValues = currentValues + } + + /** + * Updates the MarkdownConfig this component uses. + */ + fun updateConfig(config: MarkdownConfig) { + configState.set(config) + } + + fun clearSelection() { + selection?.remove() + selection = null + } + + override fun draw(matrixStack: UMatrixStack) { + if (needsInitialLayout) { + animationFrame() + } + beforeDraw(matrixStack) + + if (layoutFailed) { + getFontProvider().drawString(matrixStack, "Failed to render markdown", Color(0xCC2929), getLeft(), getTop(), 10f, 1f) + super.draw(matrixStack) + return + } + + val drawState = DrawState(getLeft() - baseX, getTop() - baseY) + val parentWindow = Window.of(this) + + drawables.forEach { + + if (!parentWindow.isAreaVisible( + it.layout.left.toDouble() + drawState.xShift, it.layout.top.toDouble() + drawState.yShift, + it.layout.right.toDouble() + drawState.xShift, it.layout.bottom.toDouble() + drawState.yShift + )) return@forEach + +// if (elementaDebug) { +// drawDebugOutline( +// matrixStack, +// it.layout.left.toDouble() + drawState.xShift, +// it.layout.top.toDouble() + drawState.yShift, +// it.layout.right.toDouble() + drawState.xShift, +// it.layout.bottom.toDouble() + drawState.yShift, +// this +// ) +// } + + it.draw(matrixStack, drawState) + } + if (!disableSelection) + selection?.draw(matrixStack, drawState) ?: cursor?.draw(matrixStack, drawState) + + super.draw(matrixStack) + } + + private fun constraintValues() = ConstraintValues( + getWidth(), + getTextScale(), + ) + + fun onLinkClicked(block: EssentialMarkdown.(LinkClickEvent) -> Unit) { + linkClickListeners.add(block) + } + + internal fun fireLinkClickEvent(event: LinkClickEvent): Boolean { + for (listener in linkClickListeners) { + this.listener(event) + + if (event.propagationStoppedImmediately) return false + } + return !event.propagationStopped + } + + class LinkClickEvent internal constructor(val url: String) : UIEvent() + + /** + * This class stores the values of the important constraints of this + * component. If these values change between frames, we need to do a + * complete re-layout of the entire markdown tree. + */ + data class ConstraintValues( + val width: Float, + val textScale: Float, + ) + + companion object { + // TODO: Remove + const val DEBUG = false + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownConfig.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownConfig.kt new file mode 100644 index 0000000..868af90 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownConfig.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown + +import java.awt.Color + +data class MarkdownConfig @JvmOverloads constructor( + val headerConfig: HeaderConfig = HeaderConfig(), + val listConfig: ListConfig = ListConfig(), + val paragraphConfig: ParagraphConfig = ParagraphConfig(), + val textConfig: TextConfig = TextConfig(), + val blockquoteConfig: BlockquoteConfig = BlockquoteConfig(), + val inlineCodeConfig: InlineCodeConfig = InlineCodeConfig(), + val codeBlockConfig: CodeBlockConfig = CodeBlockConfig(), + val urlConfig: URLConfig = URLConfig(), + val allowColors: Boolean = false, +) + +data class HeaderConfig @JvmOverloads constructor( + val fontColor: Color = Color.WHITE, + val level1: HeaderLevelConfig = HeaderLevelConfig(fontColor, 2.0f, 12f, 12f, hasDivider = true), + val level2: HeaderLevelConfig = HeaderLevelConfig(fontColor, 1.66f, 10f, 10f, hasDivider = true), + val level3: HeaderLevelConfig = HeaderLevelConfig(fontColor, 1.33f, 8f, 8f), + val level4: HeaderLevelConfig = HeaderLevelConfig(fontColor, 1.0f, 6f, 6f), + val level5: HeaderLevelConfig = HeaderLevelConfig(fontColor, 0.7f, 4f, 4f), + val level6: HeaderLevelConfig = HeaderLevelConfig(Color(155, 155, 155), 0.7f, 4f, 4f), + val enabled: Boolean = true +) + +data class HeaderLevelConfig @JvmOverloads constructor( + val fontColor: Color = Color.WHITE, + val textScale: Float = 1f, + val verticalSpaceBefore: Float = 0f, + val verticalSpaceAfter: Float = 5f, + val hasDivider: Boolean = false, + val dividerColor: Color = Color(80, 80, 80), + val dividerWidth: Float = 2f, + val spaceBeforeDivider: Float = 5f +) + +data class ListConfig @JvmOverloads constructor( + val fontColor: Color = Color.WHITE, + val indentation: Float = 10f, + val elementSpacingTight: Float = 5f, + val elementSpacingLoose: Float = 10f, + val spaceBeforeText: Float = 4f, + val spaceBeforeList: Float = 5f, + val spaceAfterList: Float = 5f, + val unorderedSymbols: String = "●◯■□", + val enabled: Boolean = true +) + +data class ParagraphConfig @JvmOverloads constructor( + val spaceBetweenLines: Float = 4f, + val spaceBefore: Float = 5f, + val spaceAfter: Float = 5f, + val softBreakIsNewline: Boolean = false, + val centered: Boolean = false +) + +data class TextConfig @JvmOverloads constructor( + val color: Color = Color.WHITE, + val hasShadow: Boolean = true, + val shadowColor: Color = Color(0x3f, 0x3f, 0x3f), + val selectionForegroundColor: Color = color, + val selectionBackgroundColor: Color = Color(0x507BBA), +) + +data class InlineCodeConfig @JvmOverloads constructor( + val fontColor: Color = Color.WHITE, + val backgroundColor: Color = Color(60, 60, 60, 255), + val outlineColor: Color = Color(140, 140, 140, 255), + val outlineWidth: Float = 0.5f, + val cornerRadius: Float = 3f, + val horizontalPadding: Float = 0f, + val verticalPadding: Float = 0f, + val enabled: Boolean = true +) + +data class CodeBlockConfig @JvmOverloads constructor( + val fontColor: Color = Color.WHITE, + val backgroundColor: Color = Color(40, 40, 40, 255), + val outlineColor: Color = Color(120, 120, 120, 255), + val outlineWidth: Float = 0.5f, + val cornerRadius: Float = 3f, + val leftPadding: Float = 5f, + val topPadding: Float = 5f, + val rightPadding: Float = 5f, + val bottomPadding: Float = 5f, + val topMargin: Float = 10f, + val bottomMargin: Float = 10f, + val enabled: Boolean = true +) + +data class URLConfig @JvmOverloads constructor( + val fontColor: Color = Color(1, 165, 82), + val fontColorOnHover: Color = Color(1, 165, 82), + val underline: Boolean = false, + val underlineOnHover: Boolean = true, + val enabled: Boolean = true +) + +data class BlockquoteConfig @JvmOverloads constructor( + val spaceBeforeDivider: Float = 3f, + val spaceAfterDivider: Float = 12f, + val spaceBeforeBlockquote: Float = 7f, + val spaceAfterBlockquote: Float = 7f, + val dividerPaddingTop: Float = 3f, + val dividerPaddingBottom: Float = 3f, + val dividerColor: Color = Color(80, 80, 80), + val dividerWidth: Float = 2f, + val enabled: Boolean = true +) diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownRenderer.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownRenderer.kt new file mode 100644 index 0000000..a205cce --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/MarkdownRenderer.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown + +import gg.essential.elementa.impl.commonmark.ext.gfm.strikethrough.Strikethrough +import gg.essential.elementa.impl.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import gg.essential.elementa.impl.commonmark.ext.ins.Ins +import gg.essential.elementa.impl.commonmark.ext.ins.InsExtension +import gg.essential.elementa.impl.commonmark.node.* +import gg.essential.elementa.impl.commonmark.parser.Parser +import gg.essential.gui.elementa.essentialmarkdown.ext.colorattribute.ColorAttribute +import gg.essential.gui.elementa.essentialmarkdown.ext.colorattribute.ColorAttributeExtension +import gg.essential.gui.elementa.essentialmarkdown.drawables.* +import java.awt.Color +import java.net.URL + +class MarkdownRenderer @JvmOverloads constructor( + text: String, + md: EssentialMarkdown, + config: MarkdownConfig = MarkdownConfig(), +) { + + private val impl = MarkdownRendererImpl(text, md, config) + + fun render(): DrawableList = impl.render() +} + +// Separate as to not expose the CommonMark implementation detail +private class MarkdownRendererImpl( + private val text: String, + private val md: EssentialMarkdown, + private val config: MarkdownConfig, +) : AbstractVisitor() { + + private val drawables = mutableListOf() + private val style = MutableStyle() + + private val marks = mutableListOf() + + private fun mark() { + marks.add(drawables.size) + } + + fun render(): DrawableList { + val enabledBlockTypes = mutableSetOf>() + with(enabledBlockTypes) { + if (config.headerConfig.enabled) add(Heading::class.java) + if (config.codeBlockConfig.enabled) { + add(FencedCodeBlock::class.java) + add(IndentedCodeBlock::class.java) + } + if (config.blockquoteConfig.enabled) add(BlockQuote::class.java) + if (config.listConfig.enabled) add(ListBlock::class.java) + } + + // This is a bit of a workaround for the fact that a delimiter processor extension (see ColorAttributeDelimiterProcessor) + // cannot share the same opening/closing characters as another delimiter processor extension. + val replacedText = text + .replace(OPENING_COLOR_TAG_REGEX, "{$1}") + .replace(CLOSING_COLOR_TAG_REGEX, "{$1}") + + val document = Parser.builder() + .extensions(extensions) + .enabledBlockTypes(enabledBlockTypes) + .build() + .parse(replacedText) + + document.accept(this) + return DrawableList(md, drawables) + } + + private fun unmarkAndCollect(): DrawableList { + val lastMark = marks.removeAt(marks.lastIndex) + val slice = drawables.subList(lastMark, drawables.size).toList() + repeat(slice.size) { + drawables.removeAt(drawables.lastIndex) + } + return DrawableList(md, slice) + } + + override fun visit(emphasis: Emphasis) { + style.isItalic = true + super.visit(emphasis) + style.isItalic = false + } + + override fun visit(strongEmphasis: StrongEmphasis) { + style.isBold = true + super.visit(strongEmphasis) + style.isBold = false + } + + override fun visit(text: Text) { + if (text.firstChild != null) + TODO() + drawables.add(TextDrawable(md, text.literal, style.toTextStyle())) + } + + override fun visit(paragraph: Paragraph) { + mark() + super.visit(paragraph) + drawables.add(ParagraphDrawable(md, unmarkAndCollect())) + } + + override fun visit(blockQuote: BlockQuote) { + mark() + super.visit(blockQuote) + drawables.add(BlockquoteDrawable(md, unmarkAndCollect())) + } + + override fun visit(bulletList: BulletList) { + mark() + super.visit(bulletList) + val children = unmarkAndCollect() + if (children.any { it !is DrawableList && it !is ListDrawable }) + TODO() + + drawables.add( + ListDrawable( + md, + children, + isOrdered = false, + isLoose = !bulletList.isTight + ) + ) + } + + override fun visit(listItem: ListItem) { + mark() + super.visit(listItem) + drawables.add(DrawableList(md, unmarkAndCollect())) + } + + override fun visit(orderedList: OrderedList) { + mark() + super.visit(orderedList) + val children = unmarkAndCollect() + if (children.any { it !is DrawableList && it !is ListDrawable }) + TODO() + + drawables.add( + ListDrawable( + md, + children, + isOrdered = true, + isLoose = !orderedList.isTight + ) + ) + } + + override fun visit(code: Code) { + style.isCode = true + drawables.add(TextDrawable(md, code.literal, style.toTextStyle())) + style.isCode = false + } + + override fun visit(fencedCodeBlock: FencedCodeBlock) { + //TODO("Not yet implemented") + } + + override fun visit(hardLineBreak: HardLineBreak) { + if (hardLineBreak.firstChild != null) + TODO() + drawables.add(HardBreakDrawable(md)) + } + + override fun visit(heading: Heading) { + mark() + super.visit(heading) + val children = unmarkAndCollect() + drawables.add(HeaderDrawable(md, heading.level, ParagraphDrawable(md, children))) + } + + override fun visit(thematicBreak: ThematicBreak) { + //TODO("Not yet implemented") + } + + override fun visit(htmlInline: HtmlInline) { + // HtmlBlocks are disabled, but HTML tags will still be parsed as inline HTML. + // This is fine, as if the text inside of the angle brackets contains any + // formatting (or anything that makes it an invalid HTML tag), it will not + // be parsed as an HTML tag. If we're here, it is unformatted and we just + // handle it like raw text. + drawables.add(TextDrawable(md, htmlInline.literal, style.toTextStyle())) + } + + override fun visit(image: Image) { + mark() + super.visit(image) + val fallback = unmarkAndCollect() + drawables.add(gg.essential.gui.elementa.essentialmarkdown.drawables.ImageDrawable(md, URL(image.destination), fallback)) + } + + override fun visit(indentedCodeBlock: IndentedCodeBlock) { + //TODO("Not yet implemented") + } + + override fun visit(link: Link) { + style.linkLocation = link.destination + super.visit(link) + style.linkLocation = null + } + + override fun visit(softLineBreak: SoftLineBreak) { + if (softLineBreak.firstChild != null) + TODO() + drawables.add(SoftBreakDrawable(md)) + } + + override fun visit(linkReferenceDefinition: LinkReferenceDefinition) { + //TODO("Not yet implemented") + } + + override fun visit(customBlock: CustomBlock) { + //TODO("Not yet implemented") + } + + override fun visit(customNode: CustomNode) { + when (customNode) { + is Strikethrough -> { + style.isStrikethrough = true + super.visit(customNode) + style.isStrikethrough = false + } + is Ins -> { + style.isUnderline = true + super.visit(customNode) + style.isUnderline = false + } + is ColorAttribute -> { + if (config.allowColors) { + style.color = customNode.color + } + + super.visit(customNode) + style.color = null + } + else -> TODO() + } + } + + data class MutableStyle( + var isBold: Boolean = false, + var isItalic: Boolean = false, + var isStrikethrough: Boolean = false, + var isUnderline: Boolean = false, + var isCode: Boolean = false, + var color: Color? = null, + var linkLocation: String? = null + ) { + fun toTextStyle() = TextDrawable.Style( + isBold, + isItalic, + isStrikethrough, + isUnderline, + isCode, + color, + linkLocation + ) + } + + companion object { + val OPENING_COLOR_TAG_REGEX = "<(color:#[0-9a-fA-F]{6,})>".toRegex() + val CLOSING_COLOR_TAG_REGEX = "".toRegex() + + private val extensions = listOf( + StrikethroughExtension.create(), + InsExtension.create(), + ColorAttributeExtension.create(), + ) + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/BlockquoteDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/BlockquoteDrawable.kt new file mode 100644 index 0000000..4ae8934 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/BlockquoteDrawable.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.elementa.components.UIBlock +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.universal.UMatrixStack + +class BlockquoteDrawable(md: EssentialMarkdown, val drawables: DrawableList) : Drawable(md) { + private var dividerHeight: Float = -1f + override val children: List get() = drawables + + var maxTextLineWidth = 0f + private set + + init { + drawables.parent = this + } + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + val config = config.blockquoteConfig + + // Horizontal padding due to the quote bar, which will shift the drawables to the right + val padding = config.spaceBeforeDivider + config.dividerWidth + config.spaceAfterDivider + + var currY = y + currY += if (insertSpaceBefore) config.spaceBeforeBlockquote else 0f + val dividerStart = currY + currY += config.dividerPaddingTop + + trim(drawables) + + drawables.forEach { + // Layout our children taking into account the quote bar padding + currY += it.layout(x + padding, currY, width - padding).height + } + + currY += config.dividerPaddingBottom + dividerHeight = currY - dividerStart + if (insertSpaceAfter) + currY += config.spaceAfterBlockquote + + val height = currY - y + + maxTextLineWidth = drawables.maxOfOrNull { drawable -> + (drawable as? ParagraphDrawable)?.maxTextLineWidth?.plus(padding) ?: 0f + } ?: 0f + + return Layout( + x, + y, + width, + height, + Margin(0f, config.spaceBeforeBlockquote, 0f, config.spaceAfterBlockquote) + ) + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + UIBlock.drawBlockSized( + matrixStack, + config.blockquoteConfig.dividerColor, + x + state.xShift + config.blockquoteConfig.spaceBeforeDivider.toDouble(), + y + state.yShift + config.blockquoteConfig.spaceBeforeBlockquote.toDouble(), + config.blockquoteConfig.dividerWidth.toDouble(), + dividerHeight.toDouble() + ) + + drawables.forEach { it.drawCompat(matrixStack, state) } + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int) = drawables.cursorAt(mouseX, mouseY, dragged, mouseButton) + override fun cursorAtStart() = drawables.cursorAtStart() + override fun cursorAtEnd() = drawables.cursorAtEnd() + + override fun selectedText(asMarkdown: Boolean): String { + if (!hasSelectedText()) + return "" + + val text = drawables.selectedText(asMarkdown) + return if (asMarkdown) { + text.lines().joinToString(separator = "\n") { "> $it" } + } else text + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/Drawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/Drawable.kt new file mode 100644 index 0000000..5f72dbc --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/Drawable.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.MarkdownConfig +import gg.essential.gui.elementa.essentialmarkdown.selection.Cursor +import gg.essential.universal.UMatrixStack +import kotlin.reflect.KMutableProperty0 +import kotlin.reflect.KProperty + +abstract class Drawable(val md: EssentialMarkdown) { + val config: MarkdownConfig + get() = md.config + + // Cache the layout between draws, as calculating this is fairly + // expensive. + lateinit var layout: Layout + + // Layout helpers + var x: Float + get() = layout.x + set(value) { layout.x = value } + var y: Float + get() = layout.y + set(value) { layout.y = value } + var width + get() = layout.width + set(value) { layout.width = value } + var height + get() = layout.height + set(value) { layout.height = value } + var margin + get() = layout.margin + set(value) { layout.margin = value } + + // Used to disable top and bottom padding in some elements + var insertSpaceBefore = true + var insertSpaceAfter = true + + // For tree-like navigation + var previous: Drawable? = null + var next: Drawable? = null + // parent == null indicates the parent is the MarkdownComponent + var parent: Drawable? = null + open val children: List = emptyList() + + /** + * Layout this element with the given x, y, and width constraints. + * Returns all of the information necessary to place both this + * component and any following components on the screen. + * + * This should be considered an expensive function call, and as such + * is only called (from MarkdownComponent) when necessary (i.e. when + * the x, y, or width values change). + * + * The result of this computation is cached in the [layout] property. + */ + fun layout(x: Float, y: Float, width: Float): Layout { + return layoutImpl(x, y, width).also { + layout = it + } + } + + /** + * Implementation of layout functionality + */ + protected abstract fun layoutImpl(x: Float, y: Float, width: Float): Layout + + @Deprecated(UMatrixStack.Compat.DEPRECATED, ReplaceWith("draw(matrixStack, state)")) + open fun draw(state: DrawState) = draw(UMatrixStack.Compat.get(), state) + + @Suppress("DEPRECATION") + fun drawCompat(matrixStack: UMatrixStack, state: DrawState) = UMatrixStack.Compat.runLegacyMethod(matrixStack) { draw(state) } + + open fun draw(matrixStack: UMatrixStack, state: DrawState) { + } + + fun isHovered(mouseX: Float, mouseY: Float): Boolean { + return mouseX in layout.left..layout.right && mouseY in layout.top..layout.bottom + } + + /** + * Produces a TextCursor for this drawable in the specified position. + * + * For higher-level drawables (like headers and lists), this simply + * delegates to a lower-level drawable (DrawableList and + * ParagraphDrawable). + */ + abstract fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int): Cursor<*> + + /** + * Produces a TextCursor for the start of this drawable + */ + abstract fun cursorAtStart(): Cursor<*> + + /** + * Produces a TextCursor for the end of this drawable + */ + abstract fun cursorAtEnd(): Cursor<*> + + /** + * Whether or not this drawable contains a selected TextDrawable anywhere + * in its children tree + */ + open fun hasSelectedText(): Boolean { + return children.any { + (it is TextDrawable && it.selectionStart != -1) || it.hasSelectedText() + } + } + + /** + * The text that is currently selected inside of this drawable. + * + * @param asMarkdown Whether or not to format the returned text as markdown + * text (e.g. "> text" vs "text" for block quotes) + */ + abstract fun selectedText(asMarkdown: Boolean): String + + data class Layout( + var x: Float, + var y: Float, + var width: Float, + var height: Float, + var margin: Margin = Margin() + ) { + val elementWidth get() = width - margin.left - margin.right + val elementHeight get() = height - margin.top - margin.bottom + + val top get() = y + val bottom get() = y + height + val elementTop get() = y + margin.top + + val left get() = x + val right get() = x + width + val elementLeft get() = x + margin.left + } + + data class Margin( + var left: Float = 0f, + var top: Float = 0f, + var right: Float = 0f, + var bottom: Float = 0f + ) + + companion object { + // To resolve the ambiguity + fun trim(drawableList: DrawableList) { + trim(drawableList as List) + } + + /** + * Disables the start padding from the first element of + * this list, as well as the end padding from the last + * element of this list + */ + fun trim(drawables: List) { + drawables.firstOrNull()?.also { + it.insertSpaceBefore = false + } + drawables.lastOrNull()?.also { + it.insertSpaceAfter = false + } + } + + /** + * Disables both the start and end padding + */ + fun trim(drawable: Drawable) { + if (drawable is DrawableList) { + drawable.first().insertSpaceBefore = false + drawable.last().insertSpaceAfter = false + } else { + drawable.insertSpaceBefore = false + drawable.insertSpaceAfter = false + } + } + } +} + +fun lazy(property: () -> KMutableProperty0): LazyPropertyDelegate { + return LazyPropertyDelegate(property) +} + +class LazyPropertyDelegate(private val provider: () -> KMutableProperty0) { + private var propertyBacker: KMutableProperty0? = null + + private fun property(): KMutableProperty0 { + if (propertyBacker == null) + propertyBacker = provider() + return propertyBacker!! + } + + operator fun getValue(d: Drawable, p: KProperty<*>): R { + return property().get() + } + + operator fun setValue(d: Drawable, p: KProperty<*>, value: R) { + property().set(value) + } +} + diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/DrawableList.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/DrawableList.kt new file mode 100644 index 0000000..b30b9f9 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/DrawableList.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.selection.Cursor +import gg.essential.universal.UMatrixStack + +/** + * Represents a list of drawables. + * + * Most markdown drawables accepts a DrawableList, which it + * will display with additional "effects" (such as a vertical + * bar in BlockquoteDrawable). This class nicely delegates + * certain behavior to its children, such as layout and + * selection. + */ +class DrawableList( + md: EssentialMarkdown, + drawables: List +) : Drawable(md), List { + private lateinit var drawables: List + override val children: List get() = drawables + + init { + setDrawables(drawables) + } + + fun setDrawables(newDrawables: List) { + drawables = newDrawables + drawables.forEach { it.parent = this } + trim(this) + + forEachIndexed { index, drawable -> + if (index > 0) + drawable.previous = this[index - 1] + if (index != lastIndex) + drawable.next = this[index + 1] + } + } + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + var currY = y + forEach { + currY += it.layout(x, currY, width).height + } + val height = currY - y + return Layout(x, y, width, height) + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int): Cursor<*> { + // Used for positioning the cursor in-between drawables if no + // drawable is being directly hovered + var closestDrawable: Drawable? = null + var closestDistance = Float.MAX_VALUE + var direction: Direction? = null + + for (drawable in drawables) { + if (drawable.isHovered(mouseX, mouseY)) { + return drawable.cursorAt(mouseX, mouseY, dragged, mouseButton) + } else { + if (mouseY < drawable.y) { + if (drawable.y - mouseY < closestDistance) { + direction = Direction.Up + closestDistance = drawable.y - mouseY + closestDrawable = drawable + } + } else if (mouseY > drawable.y + drawable.height) { + if (drawable.y + drawable.height - mouseY < closestDistance) { + direction = Direction.Down + closestDistance = mouseY - (drawable.y + drawable.height) + closestDrawable = drawable + } + } else { + // The drawable is hovered vertically, but not horizontally + closestDistance = 0f + closestDrawable = drawable + direction = if (mouseX < drawable.x) { + Direction.Left + } else Direction.Right + break + } + } + } + + if (closestDrawable == null || closestDistance == Float.MAX_VALUE || direction == null) + TODO() + + return when (direction) { + Direction.Up -> closestDrawable.cursorAtStart() + Direction.Down -> closestDrawable.cursorAtEnd() + Direction.Left, Direction.Right -> + closestDrawable.cursorAt(mouseX, mouseY, dragged, mouseButton) + } + } + + override fun cursorAtStart() = drawables.first().cursorAtStart() + + override fun cursorAtEnd() = drawables.last().cursorAtEnd() + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + forEach { it.drawCompat(matrixStack, state) } + } + + override fun selectedText(asMarkdown: Boolean): String { + return filter { + it.hasSelectedText() + }.joinToString(separator = "\n\n") { + it.selectedText(asMarkdown) + } + } + + override val size get() = drawables.size + override fun contains(element: Drawable) = element in drawables + override fun containsAll(elements: Collection) = drawables.containsAll(elements) + override fun get(index: Int) = drawables[index] + override fun indexOf(element: Drawable) = drawables.indexOf(element) + override fun isEmpty() = drawables.isEmpty() + override fun iterator() = drawables.iterator() + override fun lastIndexOf(element: Drawable) = drawables.lastIndexOf(element) + override fun listIterator() = drawables.listIterator() + override fun listIterator(index: Int) = drawables.listIterator(index) + override fun subList(fromIndex: Int, toIndex: Int) = drawables.subList(fromIndex, toIndex) + + enum class Direction { + Up, + Down, + Left, + Right + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HardBreakDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HardBreakDrawable.kt new file mode 100644 index 0000000..dddba42 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HardBreakDrawable.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.selection.Cursor +import gg.essential.universal.UMatrixStack + +/** + * A hard break is two or more line breaks between lines of + * markdown text. + */ +class HardBreakDrawable(md: EssentialMarkdown) : Drawable(md) { + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + TODO("Not yet implemented") + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + TODO("Not yet implemented") + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int): Cursor<*> { + TODO("Not yet implemented") + } + + override fun cursorAtStart(): Cursor<*> { + TODO("Not yet implemented") + } + + override fun cursorAtEnd(): Cursor<*> { + TODO("Not yet implemented") + } + + override fun selectedText(asMarkdown: Boolean): String { + TODO("Not yet implemented") + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HeaderDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HeaderDrawable.kt new file mode 100644 index 0000000..c4924fb --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/HeaderDrawable.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.elementa.components.UIBlock +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.universal.UMatrixStack + +class HeaderDrawable( + md: EssentialMarkdown, + private val level: Int, + private val paragraph: ParagraphDrawable +) : Drawable(md) { + override val children: List get() = listOf(paragraph) + internal val id = paragraph.textDrawables.joinToString(separator = " ") { it.plainText() } + + var dividerWidth: Double? = null + + init { + paragraph.parent = this + } + + private val headerConfig = when (level) { + 1 -> config.headerConfig.level1 + 2 -> config.headerConfig.level2 + 3 -> config.headerConfig.level3 + 4 -> config.headerConfig.level4 + 5 -> config.headerConfig.level5 + 6 -> config.headerConfig.level6 + else -> throw IllegalStateException() + } + + init { + paragraph.headerConfig = headerConfig + trim(paragraph) + } + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + val spaceBefore = if (insertSpaceBefore) headerConfig.verticalSpaceBefore else 0f + val spaceAfter = if (insertSpaceAfter) headerConfig.verticalSpaceAfter else 0f + paragraph.layout(x, y + spaceBefore, width) + + val height = spaceBefore + paragraph.height + spaceAfter + if (headerConfig.hasDivider) { + headerConfig.spaceBeforeDivider + headerConfig.dividerWidth + } else 0f + + return Layout( + x, + y, + width, + height, + Margin(0f, spaceBefore, 0f, spaceAfter) + ) + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + paragraph.drawCompat(matrixStack, state) + + if (headerConfig.hasDivider) { + val y = layout.bottom - layout.margin.bottom - headerConfig.dividerWidth + UIBlock.drawBlockSized( + matrixStack, + headerConfig.dividerColor, + (x + state.xShift).toDouble(), + (y + state.yShift).toDouble(), + dividerWidth ?: width.toDouble(), + headerConfig.dividerWidth.toDouble() + ) + } + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int) = paragraph.cursorAt(mouseX, mouseY, dragged, mouseButton) + override fun cursorAtStart() = paragraph.cursorAtStart() + override fun cursorAtEnd() = paragraph.cursorAtEnd() + + override fun selectedText(asMarkdown: Boolean): String { + if (!hasSelectedText()) + return "" + + val text = paragraph.selectedText(asMarkdown) + return if (asMarkdown) { + "#".repeat(level) + " $text" + } else text + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ImageDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ImageDrawable.kt new file mode 100644 index 0000000..3d7284f --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ImageDrawable.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIImage +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.XConstraint +import gg.essential.elementa.constraints.YConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import gg.essential.elementa.dsl.childOf +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.dsl.toConstraint +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.selection.ImageCursor +import gg.essential.universal.UMatrixStack +import java.awt.Color +import java.net.URL + +class ImageDrawable(md: EssentialMarkdown, val url: URL, private val fallback: Drawable) : Drawable(md) { + var selected = false + set(value) { + field = value + if (value) { + image.setColor(Color(200, 200, 255, 255).toConstraint()) + } else { + image.setColor(Color.WHITE.toConstraint()) + } + } + + private lateinit var imageX: ShiftableMDPixelConstraint + private lateinit var imageY: ShiftableMDPixelConstraint + + private val image = UIImage.ofURL(url) childOf md + private var hasLoaded = false + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + return if (image.isLoaded) { + imageX = ShiftableMDPixelConstraint(x, 0f) + imageY = ShiftableMDPixelConstraint(y, 0f) + image.setX(imageX) + image.setY(imageY) + + val aspectRatio = image.imageWidth / image.imageHeight + val imageWidth = image.imageWidth.coerceAtMost(width) + val imageHeight = imageWidth / aspectRatio + + image.setWidth(imageWidth.pixels()) + image.setHeight(imageHeight.pixels()) + + Layout(x, y, imageWidth, imageHeight) + } else fallback.layout(x, y, width) + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + if (!image.isLoaded) { + fallback.drawCompat(matrixStack, state) + } else { + if (!hasLoaded) { + hasLoaded = true + md.layout() + } + + imageX.shift = state.xShift + imageY.shift = state.yShift + image.drawCompat(matrixStack) + } + } + + // ImageDrawable mouse selection is managed by ParagraphDrawable#select + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int) = throw IllegalStateException("never called") + override fun cursorAtStart() = ImageCursor(this) + override fun cursorAtEnd() = ImageCursor(this) + + override fun selectedText(asMarkdown: Boolean): String { + if (asMarkdown) { + // TODO: `fallback.selectedText(true)` will be empty since the children aren't + // marked as selected + return " ![${fallback.selectedText(true)}]($url) " + } + return " $url " + } + + // TODO: Rename this function? + override fun hasSelectedText() = selected + + private inner class ShiftableMDPixelConstraint(val base: Float, var shift: Float) : XConstraint, YConstraint { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getXPositionImpl(component: UIComponent) = base + shift + override fun getYPositionImpl(component: UIComponent) = base + shift + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { } + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ListDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ListDrawable.kt new file mode 100644 index 0000000..0a64630 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ListDrawable.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.elementa.dsl.width +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.universal.UMatrixStack + +class ListDrawable( + md: EssentialMarkdown, + private val drawables: DrawableList, + private val isOrdered: Boolean, + /** + * A "loose" list is a list in which any of its list items are + * separated by blank lines, or if any item contains two block + * elements with a blank line between them. A loose list has more + * separation between list elements than a tight list does. + * + * Reference: https://spec.commonmark.org/0.28/#tight + */ + private var isLoose: Boolean +) : Drawable(md) { + private val listItems = mutableListOf() + override val children: List get() = listItems + + private val elementSpacing: Float + get() = if (isLoose) { + config.listConfig.elementSpacingLoose + } else config.listConfig.elementSpacingTight + + /** + * The indentation of this list in any parent lists. This is set + * below in the layoutImpl method + */ + private var indentLevel = 0 + + // The width of the longest list entry, including the indentation, symbol width, and space after symbol + var maxTextLineWidth = 0f + private set + + init { + trim(drawables) + drawables.parent = this + } + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + listItems.clear() + + val marginTop = if (insertSpaceBefore) config.listConfig.spaceBeforeList else 0f + val marginBottom = if (insertSpaceAfter) config.listConfig.spaceAfterList else 0f + var currY = y + marginTop + val spaceAfterSymbol = config.listConfig.spaceBeforeText + val indentation = config.listConfig.indentation + + var orderedListShift = 0 + + fun symbol(index: Int): String { + return if (isOrdered) { + "${index + 1 - orderedListShift}." + } else { + val symbols = config.listConfig.unorderedSymbols + symbols[indentLevel % symbols.length].toString() + } + } + + // Get the maximum width of all list item symbols to align + // them with each other. Unordered lists have the same symbol + // at each level, however ordered lists are variable width. + val symbolWidth = if (isOrdered) { + val dotWidth = '.'.width() + drawables.indices.filter { + drawables[it] !is ListDrawable + }.maxOf { + it.toString().width() + dotWidth + } + } else { + val symbols = config.listConfig.unorderedSymbols + symbols[indentLevel % symbols.length].width() + } + + var index = 0 + + fun addItem(drawable: Drawable) { + val item = ListEntry( + md, + symbol(index), + symbolWidth, + spaceAfterSymbol, + drawable + ) + listItems.add(item) + currY += item.layout(x + indentation, currY, width - indentation).height + currY += elementSpacing + } + + for (drawable in drawables) { + if (drawable is ListDrawable) + drawable.indentLevel = indentLevel + 1 + addItem(drawable) + index++ + } + + maxTextLineWidth = drawables.maxOfOrNull { drawable -> + drawable.children.filterIsInstance().maxOfOrNull { + indentation + symbolWidth + spaceAfterSymbol + it.maxTextLineWidth + } ?: 0f + } ?: 0f + + currY -= elementSpacing + currY += marginBottom + + val height = currY - y + + return Layout( + x, + y, + width, + height, + Margin(0f, marginTop, 0f, marginBottom) + ) + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + listItems.forEach { it.drawCompat(matrixStack, state) } + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int) = drawables.cursorAt(mouseX, mouseY, dragged, mouseButton) + override fun cursorAtStart() = drawables.cursorAtStart() + override fun cursorAtEnd() = drawables.cursorAtEnd() + + override fun selectedText(asMarkdown: Boolean) = listItems.joinToString(separator = "\n") { + it.selectedText(asMarkdown) + } + + // A mostly organized and ready-to-render list item + inner class ListEntry( + md: EssentialMarkdown, + private val symbol: String, + private val symbolWidth: Float, + private val symbolPaddingRight: Float, + val drawable: Drawable + ) : Drawable(md) { + private val actualSymbolWidth = symbol.width() + override val children: List get() = listOf(drawable) + + init { + trim(drawable) + + // trim any space around list elements + if (drawable is DrawableList) { + for ((index, item) in drawable.withIndex()) { + if (item is ListDrawable) { + if (index != 0) + trim(drawable[index - 1]) + if (index != drawable.lastIndex) + trim(drawable[index + 1]) + } + } + } + } + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + val nonDrawableSpace = symbolWidth + symbolPaddingRight + drawable.layout(x + nonDrawableSpace, y, width - nonDrawableSpace) + return Layout(x, y, width, drawable.height) + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + val newX = x + symbolWidth - actualSymbolWidth + if (drawable !is ListDrawable) + TextDrawable.drawString( + matrixStack, + config, + md.getFontProvider(), + symbol, + newX + state.xShift, + y + state.yShift + ) + drawable.drawCompat(matrixStack, state) + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int) = + drawable.cursorAt(mouseX, mouseY, dragged, mouseButton) + + override fun cursorAtStart() = drawable.cursorAtStart() + override fun cursorAtEnd() = drawable.cursorAtEnd() + + override fun selectedText(asMarkdown: Boolean): String { + if (!hasSelectedText()) + return "" + + val text = drawable.selectedText(asMarkdown) + + return buildString { + repeat(indentLevel) { + append(" ") + } + if (asMarkdown) { + append(symbol) + append(' ') + } + append(text) + } + } + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ParagraphDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ParagraphDrawable.kt new file mode 100644 index 0000000..7516848 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/ParagraphDrawable.kt @@ -0,0 +1,458 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.dsl.width +import gg.essential.elementa.utils.withAlpha +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.HeaderLevelConfig +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.selection.Cursor +import gg.essential.gui.elementa.essentialmarkdown.selection.ImageCursor +import gg.essential.gui.elementa.essentialmarkdown.selection.TextCursor +import gg.essential.universal.UDesktop +import gg.essential.universal.UMatrixStack +import java.awt.Color +import java.net.URI +import java.net.URISyntaxException +import kotlin.math.abs +import kotlin.math.floor + +class ParagraphDrawable( + md: EssentialMarkdown, + private val originalDrawables: DrawableList, +) : Drawable(md) { + @Deprecated("Use children instead", ReplaceWith("children")) + val drawables = DrawableList(md, originalDrawables) + + override val children: List + get() = drawables + val textDrawables: List + get() = children.filterIsInstance() + + // The width of the longest TextDrawable line after lines are split + var maxTextLineWidth = 0f + private set + + // Used by HeaderDrawable + internal var headerConfig: HeaderLevelConfig? = null + set(value) { + field = value + scaleModifier = value?.textScale ?: scaleModifier + textDrawables.forEach { + it.headerConfig = value + } + } + + var scaleModifier = 1f + set(value) { + field = value + drawables.filterIsInstance().forEach { + it.scaleModifier = value + } + } + + init { + originalDrawables.parent = this + drawables.parent = this + } + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + val marginTop = if (insertSpaceBefore) config.paragraphConfig.spaceBefore else 0f + val marginBottom = if (insertSpaceAfter) config.paragraphConfig.spaceAfter else 0f + + // We need to build a new drawable list, as text drawables may be split + // into two or more during layout. + val newDrawables = mutableListOf() + + var currX = x + var currY = y + marginTop + var widthRemaining = width + val centered = config.paragraphConfig.centered + + // Used to trim text components which are at the start of the line + // or after a soft break so we don't render extra spaces + var trimNextText = true + + // These are used for centered text. When we render centered markdown, + // we layout all of our text drawables as normal, and center them after. + // These lists help keep track of which drawables are on their own lines. + val lines = mutableListOf>() + val currentLine = mutableListOf() + var maxLineHeight = Float.MIN_VALUE + + var prevY = y + + fun gotoNextLine() { + prevY = currY + + currX = x + currY += maxLineHeight * scaleModifier + config.paragraphConfig.spaceBetweenLines + + if (maxLineHeight > 9f) { + for (drawable in currentLine) + drawable.y += (maxLineHeight - drawable.height) / 2f + } + + maxLineHeight = Float.MIN_VALUE + + widthRemaining = width + lines.add(currentLine.toList()) + currentLine.clear() + trimNextText = true + } + + fun layout(drawable: Drawable, width: Float) { + drawable.layout(currX, currY, width).also { + if (it.height > maxLineHeight) + maxLineHeight = it.height + } + widthRemaining -= width + currX += width + currentLine.add(drawable) + newDrawables.add(drawable) + } + + fun checkThenTrimTextDrawable(textDrawable: TextDrawable) { + if (trimNextText) { + textDrawable.ensureTrimmed() + trimNextText = false + } + } + + for ((index, text) in originalDrawables.withIndex()) { + if (text is SoftBreakDrawable || text is HardBreakDrawable) { + if (config.paragraphConfig.softBreakIsNewline || text is HardBreakDrawable) { + gotoNextLine() + } else { + val previousStyle = (newDrawables.lastOrNull { it is TextDrawable } as? TextDrawable)?.let { + it.style.copy(isCode = false) + } ?: TextDrawable.Style.EMPTY + val newText = TextDrawable(md, " ", previousStyle) + + // Do this before laying out newText, so that newText isn't in the + // newDrawables list yet + if (newDrawables.isNotEmpty() && index != originalDrawables.lastIndex) { + val previous = newDrawables.last() + val next = originalDrawables[index + 1] + if (previous is TextDrawable && next is TextDrawable && previous.style == next.style) { + // Link the two texts together, as a soft break (when not + // treated as a new line) should not interrupt a link + val linkedTexts = TextDrawable.LinkedTexts.merge(previous.linkedTexts, next.linkedTexts) + linkedTexts.linkText(previous) + linkedTexts.linkText(newText) + linkedTexts.linkText(next) + previous.linkedTexts = linkedTexts + newText.linkedTexts = linkedTexts + next.linkedTexts = linkedTexts + } + } + layout(newText, newText.width()) + if (widthRemaining <= 0) + gotoNextLine() + trimNextText = true + } + continue + } + + if (text is ImageDrawable) { + gotoNextLine() + layout(text, width) + gotoNextLine() + continue + } + + if (text !is TextDrawable) + TODO() + + var target: TextDrawable = text + + while (true) { + // We don't want spaces at the start of a drawable if it is the + // first drawable in the line. + checkThenTrimTextDrawable(target) + + val targetWidth = target.width() + if (targetWidth <= widthRemaining) { + // We can just layout this text drawable inline, next to the last one + layout(target, targetWidth) + if (widthRemaining <= 0) + gotoNextLine() + break + } + + val splitResult = target.split(widthRemaining) + if (splitResult != null) { + // We successfully split the text component up. Draw the + // first part on this line, and deal with the second part + // during the next loop iteration + layout(splitResult.first, targetWidth) + gotoNextLine() + target = splitResult.second + continue + } + + // If we can't split the text in a way that doesn't break + // a word, we'll just draw the whole thing on the next line. + // Only need to advance onto the next line, if there is + // actually something on this one. Otherwise we're already + // good to take the whole line. + if (currentLine.isNotEmpty()) { + gotoNextLine() + } + + // Before we do that though, we have to make sure that its + // width isn't greater than the width of the entire component. + // If it is, we need to split it on the overall width and + // continue this splitting loop + + if (targetWidth > width) { + val splitResult2 = target.split(width) + + if (splitResult2 == null) { + // Edge case where the width of the MarkdownComponent is + // probably very small, and we can't split it on a word + // boundary. In this case we opt to split again, breaking + // words if we have to. We run split twice here, but as + // this is a rare edge case, it's not a problem. + val splitResult3 = target.split(width, breakWords = true) ?: throw IllegalStateException( + "MarkdownComponent's width (${md.getWidth()}) is too small to render its content" + ) + + val currentLineResult = splitResult3.first + checkThenTrimTextDrawable(currentLineResult) + layout(currentLineResult, currentLineResult.width()) + gotoNextLine() + target = splitResult3.second + continue + } + + // We've split the component based on the overall width. We'll + // draw the first part on this line, and the second part on the + // next line during the next loop iteration. + val currentLineResult = splitResult2.first + checkThenTrimTextDrawable(currentLineResult) + layout(currentLineResult, currentLineResult.width()) + gotoNextLine() + target = splitResult2.second + continue + } + + // We can draw the target on the next line + checkThenTrimTextDrawable(target) + layout(target, targetWidth) + break + } + } + + // We can have extra drawables in the current line that didn't get handled + // by the last iteration of the loop + if (currentLine.isNotEmpty()) + lines.add(currentLine.toList()) + + if (centered) { + // Offset each text component by half of the space at the end of each line + for (line in lines) { + val totalWidth = line.sumOf { + (it as? TextDrawable)?.width()?.toDouble() ?: it.width.toDouble() + }.toFloat() + val shift = (width - totalWidth) / 2f + for (text in line) { + text.x += shift + } + } + } + + maxTextLineWidth = lines.maxOfOrNull { line -> + line.sumOf { (it as? TextDrawable)?.width()?.toDouble() ?: it.width.toDouble() }.toFloat() + } ?: 0f + + newDrawables.forEach { + if (it is TextDrawable) + it.scaleModifier = scaleModifier + } + + drawables.setDrawables(newDrawables) + + val height = (if (currentLine.isNotEmpty()) currY else prevY) - y + 9f * scaleModifier + + if (insertSpaceAfter) config.paragraphConfig.spaceAfter else 0f + + return Layout( + x, + y, + width, + height, + Margin(0f, marginTop, 0f, marginBottom) + ) + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + drawables.filterIsInstance().forEach { it.beforeDraw(state) } + drawables.forEach { it.drawCompat(matrixStack, state) } + + // TODO: Remove + if (EssentialMarkdown.DEBUG) { + UIBlock.drawBlockSized( + matrixStack, + rc, + layout.elementLeft.toDouble() + state.xShift, + layout.elementTop.toDouble() + state.yShift, + layout.elementWidth.toDouble(), + layout.elementHeight.toDouble() + ) + } + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int): Cursor<*> { + // Account for padding between lines + // TODO: Don't account for this padding for the first and last lines? + val linePadding = config.paragraphConfig.spaceBetweenLines / 2f + + fun yRange(d: Drawable) = (d.y - linePadding)..(d.y + d.height + linePadding) + + // Step 1: Get to the correct row + + val firstInRow = drawables.firstOrNull { + mouseY in yRange(it) + } + + // Ensure that the mouseY position actually falls within this drawable. + // If not, we'll just select either the start of end of the component, + // depending on the mouseY position + if (firstInRow == null) { + if (mouseY < drawables.first().y - linePadding) { + // The position occurs before this paragraph, so we just + // select the start of this paragraph + return cursorAtStart() + } + + // The mouse isn't in this drawable, and it isn't before this + // drawable, so it must be after this drawable + return cursorAtEnd() + } + + // Step 2: Get to the correct text drawable + + if (mouseX < firstInRow.x) { + // Because we iterate text drawables top to bottom, left to right, + // if the mouseX is left of the text start, we can just select the + // start of the current component. We don't have to walk the text + // siblings (using text.previous) because firstTextInRow is the + // first text component which has an acceptable y-range. + return firstInRow.cursorAtStart() + } + + // We've selected a drawable based on the y position, now we must do + // the same thing in the x direction. This time, though, we need to + // be careful to always check the y range of the next drawable. If + // mouseY ever falls outside of the drawable y range, then the mouse + // is to the right of this paragraph drawable, and we'll select the + // drawable which we are currently on + + var currentDrawable: Drawable = firstInRow + + while (mouseX > currentDrawable.x + currentDrawable.width && currentDrawable.next != null) { + var nextDrawable = currentDrawable.next!! + + while (nextDrawable !is TextDrawable && nextDrawable !is ImageDrawable && nextDrawable.next != null) { + nextDrawable = nextDrawable.next!! + } + + if (nextDrawable !is TextDrawable && nextDrawable !is ImageDrawable) { + // currentText is the last text in this paragraph, so we'll just + // select its end + return currentDrawable.cursorAtEnd() + } + + if (mouseY !in yRange(nextDrawable)) { + // As mentioned above, the mouse is to the right of this paragraph + // component + return currentDrawable.cursorAtEnd() + } + + currentDrawable = nextDrawable + } + + // Step 3: If the hovered drawable is an image, we return early + + if (currentDrawable is ImageDrawable) + return ImageCursor(currentDrawable) + + if (currentDrawable !is TextDrawable) + TODO() + + // Step 4: If the current text is linked, open it (only if we're not dragging though) + // TODO: Confirmation modal somehow? + + currentDrawable.style.linkLocation?.takeIf { !dragged && mouseButton == 0 }?.let { linkLocation -> + if (md.fireLinkClickEvent(EssentialMarkdown.LinkClickEvent(linkLocation))) { + try { + UDesktop.browse(URI(linkLocation)) + } catch (e: URISyntaxException) { + // Ignored, if the link is invalid we just do nothing + } + } + } + + // Step 5: Get the string offset position in the current text + + fun textWidth(offset: Int) = currentDrawable.formattedText.substring(0, offset).width(currentDrawable.scaleModifier) + + var offset = currentDrawable.style.numFormattingChars + var cachedWidth = 0f + + // Iterate from left to right in the text component until we find a good + // offset based on the text width + while (offset < currentDrawable.formattedText.length) { + offset++ + val newWidth = textWidth(offset) + + if (currentDrawable.x + newWidth > mouseX) { + // We've passed mouseX, but now we have to consider which offset + // is closer to mouseX: `offset` or `offset - 1`. We check that + // here and use the closest offset + + val oldDist = abs(mouseX - currentDrawable.x - cachedWidth) + val newDist = abs(newWidth - (mouseX - currentDrawable.x)) + + if (oldDist < newDist) { + // The old offset was better + offset-- + } + + return TextCursor(currentDrawable, offset - currentDrawable.style.numFormattingChars) + } + + cachedWidth = newWidth + } + + return currentDrawable.cursorAtEnd() + } + + override fun cursorAtStart() = drawables.first { it is TextDrawable || it is ImageDrawable }.cursorAtStart() + override fun cursorAtEnd() = drawables.last { it is TextDrawable || it is ImageDrawable }.cursorAtEnd() + + override fun selectedText(asMarkdown: Boolean): String { + return drawables.filter { + it is TextDrawable || it is ImageDrawable + }.joinToString(separator = " ") { it.selectedText(asMarkdown) } + } + + private val rc = randomColor().withAlpha(100) + + private fun randomColor(): Color { + return Color(randomComponent(), randomComponent(), randomComponent()) + } + + private fun randomComponent(): Int = floor(Math.random() * 256f).toInt() +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/SoftBreakDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/SoftBreakDrawable.kt new file mode 100644 index 0000000..a3cbb8c --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/SoftBreakDrawable.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.selection.Cursor +import gg.essential.universal.UMatrixStack + +/** + * A soft break is one line break between lines of markdown text. + */ +class SoftBreakDrawable(md: EssentialMarkdown) : Drawable(md) { + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + TODO("Not yet implemented") + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + TODO("Not yet implemented") + } + + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int): Cursor<*> { + TODO("Not yet implemented") + } + + override fun cursorAtStart(): Cursor<*> { + TODO("Not yet implemented") + } + + override fun cursorAtEnd(): Cursor<*> { + TODO("Not yet implemented") + } + + override fun selectedText(asMarkdown: Boolean): String { + TODO("Not yet implemented") + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/TextDrawable.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/TextDrawable.kt new file mode 100644 index 0000000..288e77e --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/drawables/TextDrawable.kt @@ -0,0 +1,455 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.drawables + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIRoundedRectangle +import gg.essential.elementa.dsl.* +import gg.essential.elementa.font.FontProvider +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.HeaderLevelConfig +import gg.essential.gui.elementa.essentialmarkdown.MarkdownConfig +import gg.essential.gui.elementa.essentialmarkdown.selection.TextCursor +import gg.essential.universal.UMatrixStack +import gg.essential.universal.UMouse +import java.awt.Color + +class TextDrawable( + md: EssentialMarkdown, + text: String, + val style: Style +) : Drawable(md) { + // Used by HeaderDrawable + var scaleModifier = 1f + // FIXME: Should probably be handled by [Style] at some point. + internal var headerConfig: HeaderLevelConfig? = null + set(value) { + field = value + scaleModifier = value?.textScale ?: scaleModifier + } + + var formattedText: String = style.formattingSymbols + text + private set + + // Used by the selection API to tell this Drawable how it is + // selected. These do not consider style characters + var selectionStart = -1 + var selectionEnd = -1 + + // Used to store the Text classes to render between beforeDraw and draw + private var texts = mutableListOf() + + // Stores whether or not this component is hovered + private var isHovered = false + + // Populated with any text drawables which used to be part of this + // drawable, but were split with a call to split(). Used to show + // hovered links across newline boundaries + var linkedTexts: LinkedTexts? = null + + fun plainText() = formattedText.drop(style.numFormattingChars) + + fun ensureTrimmed() { + // TODO: We shouldn't mutate formattedText here, because this is used + // conditionally based on the position this text drawable _happens_ to + // be rendered in its parent ParagraphDrawable. This may change if the + // MarkdownComponent re-layouts, which can happen at any time. + + val styleChars = style.numFormattingChars + formattedText = formattedText.substring(0, styleChars) + + formattedText.substring(styleChars, formattedText.length).trimStart() + } + + fun width() = formattedText.width(scaleModifier) + if (style.isCode) { + config.inlineCodeConfig.let { + (it.outlineWidth + it.horizontalPadding) * 2f + } + } else 0f + + // Returns null if this drawable cannot be split in a way that doesn't + // break a word. This means that the drawable should just be drawn on + // the next line + fun split(maxWidth: Float, breakWords: Boolean = false): Pair? { + val styleChars = style.numFormattingChars + val plainText = plainText() + + if (plainText.length <= 1) { + return null + } + + var splitPoint = formattedText.indices.drop(styleChars).firstOrNull { + formattedText.substring(0, it + 1).width(scaleModifier) > maxWidth + } ?: throw IllegalStateException("TextDrawable#split called when it should not have been called") + + splitPoint -= styleChars + + if (!breakWords) { + while (splitPoint > styleChars && formattedText[splitPoint] != ' ') { + splitPoint-- + } + + if (splitPoint == styleChars) { + return null + } + } + + if (splitPoint <= 0) { + splitPoint = 1 + } + + val first = TextDrawable(md, plainText.substring(0, splitPoint).trimEnd(), style) + val second = TextDrawable(md, plainText.substring(splitPoint, plainText.length), style) + + val linkedTexts = this.linkedTexts?.also { + // We are splitting this text drawable, so in effect this + // drawable no longer "exists", because it isn't relevant. + // Therefore, we remove it from this linked text group + it.unlinkText(this) + } ?: LinkedTexts() + + linkedTexts.linkText(first) + linkedTexts.linkText(second) + + first.linkedTexts = linkedTexts + second.linkedTexts = linkedTexts + + first.scaleModifier = scaleModifier + second.scaleModifier = scaleModifier + + return first to second + } + + override fun layoutImpl(x: Float, y: Float, width: Float): Layout { + return Layout( + x, + y, + width, + 9f * scaleModifier + if (style.isCode) { + (config.inlineCodeConfig.let { it.verticalPadding + it.outlineWidth }) * 2f + } else 0f + ) + } + + fun beforeDraw(state: DrawState) { + texts.clear() + + if (selectionStart == -1 && selectionEnd == -1) { + texts.add(Text(formattedText, x, y, false)) + } else if (selectionStart == -1 || selectionEnd == -1) { + throw IllegalStateException() + } else { + val start = selectionStart + style.numFormattingChars + val end = selectionEnd + style.numFormattingChars + + val formatChars = formattedText.substring(0, style.numFormattingChars) + + val nextX = if (selectionStart > 0) { + texts.add( + Text( + formattedText.substring(0, start), + x, + y, + false + ) + ) + x + formattedText.substring(0, start).width(scaleModifier) + } else x + + val selectedString = formatChars + formattedText.substring(start, end) + texts.add( + Text( + selectedString, + nextX, + y, + true + ) + ) + + if (end < formattedText.length) { + texts.add( + Text( + formatChars + formattedText.substring(end), + nextX + selectedString.width(scaleModifier), + y, + false + ) + ) + } + } + + val mouseX = UMouse.Scaled.x - state.xShift + val mouseY = UMouse.Scaled.y - state.yShift + isHovered = if (style.linkLocation != null) { + isHovered(mouseX.toFloat(), mouseY.toFloat()) + } else false + } + + override fun draw(matrixStack: UMatrixStack, state: DrawState) { + val hovered = isHovered || (linkedTexts?.isHovered() ?: false) + + if (style.isCode) { + val x1 = x + state.xShift + val y1 = y + state.yShift - 1f + val x2 = x1 + width + val y2 = y1 + height + config.inlineCodeConfig.verticalPadding * 2 - 1f + val outlineWidth = config.inlineCodeConfig.outlineWidth + + UIRoundedRectangle.drawRoundedRectangle( + matrixStack, + x1, + y1, + x2, + y2, + config.inlineCodeConfig.cornerRadius, + config.inlineCodeConfig.outlineColor + ) + + UIRoundedRectangle.drawRoundedRectangle( + matrixStack, + x1 + outlineWidth, + y1 + outlineWidth, + x2 - outlineWidth, + y2 - outlineWidth, + config.inlineCodeConfig.cornerRadius, + config.inlineCodeConfig.backgroundColor + ) + } + + val xShift = state.xShift + if (style.isCode) config.inlineCodeConfig.horizontalPadding else 0f + val yShift = state.yShift + if (style.isCode) config.inlineCodeConfig.verticalPadding else 0f + + texts.forEach { + matrixStack.scale(scaleModifier, scaleModifier, 1f) + drawString( + matrixStack, + config, + md.getFontProvider(), + it.string, + (it.x + xShift) / scaleModifier, + (it.y + yShift) / scaleModifier, + it.selected, + style.linkLocation != null, + hovered, + style.color, + headerConfig + ) + matrixStack.scale(1f / scaleModifier, 1f / scaleModifier, 1f) + } + } + + data class Text( + val string: String, + val x: Float, + val y: Float, + val selected: Boolean + ) + + // TextDrawable mouse selection is managed by ParagraphDrawable#select + override fun cursorAt(mouseX: Float, mouseY: Float, dragged: Boolean, mouseButton: Int) = throw IllegalStateException("never called") + + override fun cursorAtStart() = TextCursor(this, 0) + override fun cursorAtEnd() = TextCursor(this, plainText().length) + + override fun selectedText(asMarkdown: Boolean): String { + if (selectionStart == -1 || selectionEnd == -1) + return "" + + val selectedText = plainText().substring(selectionStart, selectionEnd) + if (!asMarkdown) + return selectedText + + return "${style.markdownSymbols}$selectedText${style.markdownSymbols}" + } + + override fun toString() = formattedText + + class LinkedTexts { + private val texts = mutableSetOf() + + fun isHovered() = texts.any { it.isHovered } + + fun linkText(text: TextDrawable) { + texts.add(text) + } + + fun unlinkText(text: TextDrawable) { + texts.remove(text) + } + + companion object { + fun merge(linked1: LinkedTexts?, linked2: LinkedTexts?): LinkedTexts { + return when { + linked1 == null && linked2 == null -> LinkedTexts() + linked1 == null -> linked2!! + linked2 == null -> linked1 + else -> { + linked2.texts.forEach { + linked1.linkText(it) + } + linked1 + } + } + } + } + } + + data class Style( + val isBold: Boolean, + val isItalic: Boolean, + val isStrikethrough: Boolean, + val isUnderline: Boolean, + val isCode: Boolean, + val color: Color?, + val linkLocation: String? + ) { + val formattingSymbols = buildString { + if (isCode) + return@buildString + if (isBold) + append("§l") + if (isItalic) + append("§o") + if (isStrikethrough) + append("§m") + if (isUnderline) + append("§n") + } + + val markdownSymbols = buildString { + if (isCode) + append("`") + if (isBold) + append("**") + if (isItalic) + append("*") + if (isStrikethrough) + append("~~") + if (isUnderline) + append("++") + } + + val numFormattingChars: Int get() = formattingSymbols.length + + companion object { + val EMPTY = Style( + isBold = false, + isItalic = false, + isStrikethrough = false, + isUnderline = false, + isCode = false, + color = null, + linkLocation = null + ) + } + } + + companion object { + @Deprecated( + UMatrixStack.Compat.DEPRECATED, + ReplaceWith("drawString(matrixStack, config, fontProvider, string, x, y, selected, isLink, isHovered)"), + ) + fun drawString( + config: MarkdownConfig, + fontProvider: FontProvider, + string: String, + x: Float, + y: Float, + selected: Boolean = false, + isLink: Boolean = false, + isHovered: Boolean = false + ) = drawString(UMatrixStack(), config, fontProvider, string, x, y, selected, isLink, isHovered) + + fun drawString( + matrixStack: UMatrixStack, + config: MarkdownConfig, + fontProvider: FontProvider, + string: String, + x: Float, + y: Float, + selected: Boolean = false, + isLink: Boolean = false, + isHovered: Boolean = false, + color: Color? = null, + headerConfig: HeaderLevelConfig? = null + ) { + if (selected) { + UIBlock.drawBlockSized( + matrixStack, + config.textConfig.selectionBackgroundColor, + x.toDouble(), + y.toDouble(), + string.width(1f, fontProvider).toDouble(), + 9.0 + ) + } + + val foregroundColor = when { + isLink && isHovered -> config.urlConfig.fontColorOnHover.rgb + isLink -> config.urlConfig.fontColor.rgb + selected -> config.textConfig.selectionForegroundColor.rgb + headerConfig != null -> headerConfig.fontColor.rgb + color != null -> color.rgb + else -> config.textConfig.color.rgb + } + + if (config.textConfig.hasShadow) { + fontProvider.drawString( + matrixStack, + string, + Color(foregroundColor), + x, + y, + 10f, + 1f, + true, + Color(config.textConfig.shadowColor.rgb) + ) + } else { + fontProvider.drawString( + matrixStack, + string, + Color(foregroundColor), + x, + y, + 10f, + 1f, + false + ) + } + + // Underline if link is: + // - not hovered and 'underline' is true + // - hovered and 'underlineOnHover' is true + if (isLink && ((!isHovered && config.urlConfig.underline) || (isHovered && config.urlConfig.underlineOnHover))) { + if (config.textConfig.hasShadow) { + UIBlock.drawBlockSized( + matrixStack, + config.textConfig.shadowColor, + x.toDouble() + 1, + y.toDouble() + 9, + string.width().toDouble(), + 1.0, + ) + } + UIBlock.drawBlockSized( + matrixStack, + if (isHovered) config.urlConfig.fontColorOnHover else config.urlConfig.fontColor, + x.toDouble(), + y.toDouble() + 8, + string.width().toDouble(), + 1.0 + ) + } + } + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttribute.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttribute.kt new file mode 100644 index 0000000..7e21fe3 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttribute.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.ext.colorattribute + +import gg.essential.elementa.impl.commonmark.node.CustomNode +import java.awt.Color + +class ColorAttribute(val color: Color) : CustomNode() { + override fun toString(): String { + return "ColorAttribute{color=$color}" + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttributeExtension.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttributeExtension.kt new file mode 100644 index 0000000..fb3d616 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/ColorAttributeExtension.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.ext.colorattribute + +import gg.essential.elementa.impl.commonmark.parser.Parser +import gg.essential.gui.elementa.essentialmarkdown.ext.colorattribute.internal.ColorAttributeDelimiterProcessor + +class ColorAttributeExtension : Parser.ParserExtension { + + override fun extend(builder: Parser.Builder) { + builder.customDelimiterProcessor(ColorAttributeDelimiterProcessor()) + } + + companion object { + fun create() = ColorAttributeExtension() + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/internal/ColorAttributeDelimiterProcessor.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/internal/ColorAttributeDelimiterProcessor.kt new file mode 100644 index 0000000..7006d60 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/ext/colorattribute/internal/ColorAttributeDelimiterProcessor.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.ext.colorattribute.internal + +import gg.essential.elementa.impl.commonmark.node.Node +import gg.essential.elementa.impl.commonmark.node.Nodes +import gg.essential.elementa.impl.commonmark.node.SourceSpans +import gg.essential.elementa.impl.commonmark.node.Text +import gg.essential.elementa.impl.commonmark.parser.delimiter.DelimiterProcessor +import gg.essential.elementa.impl.commonmark.parser.delimiter.DelimiterRun +import gg.essential.gui.elementa.essentialmarkdown.ext.colorattribute.ColorAttribute +import java.awt.Color + +class ColorAttributeDelimiterProcessor : DelimiterProcessor { + private val openingTag = "color:" + private val closingTag = "color" + + override fun getOpeningCharacter(): Char = '{' + override fun getClosingCharacter(): Char = '}' + override fun getMinLength(): Int = 1 + + override fun process(opener: DelimiterRun, closer: DelimiterRun): Int { + val openerNode = opener.opener + val colorNode = openerNode.next as? Text ?: return 0 + + // Opening tag + if (colorNode.literal.startsWith(openingTag)) { + val hexCode = colorNode.literal.removePrefix(openingTag) + + val colorAttribute = ColorAttribute( + try { + Color.decode(hexCode) + } catch (exception: NumberFormatException) { + System.err.println("Invalid color code: $hexCode") + return 0 + } + ) + + val sourceSpans = SourceSpans() + + // Get all the nodes between the opening and closing tags + val parent = openerNode.parent + val sibling = openerNode.previous + val allNodes = listOf(openerNode) + Nodes.between(openerNode, parent.lastChild) + if (parent.lastChild != openerNode) listOf(parent.lastChild) else emptyList() + + var openingColorTags = 0 + val nodesToColor = mutableListOf() + for (node in allNodes) { + nodesToColor.add(node) + sourceSpans.addAll(node.sourceSpans) + + // Add to the color counter to skip its nested closing tag + if (node != colorNode && isOpeningTag(node)) { + openingColorTags++ + } + + if (isClosingTag(node.previous)) { + // Stop once we hit the final closing tag + if (openingColorTags == 0) { + break + } + + // Decrement the color counter now that we've reached its nested closing tag + openingColorTags-- + } + } + + if (openingColorTags > 0) { + // No final closing tag; invalid syntax + return 0 + } + + colorAttribute.sourceSpans = sourceSpans.sourceSpans + nodesToColor.forEach { colorAttribute.appendChild(it) } + colorNode.unlink() + + if (sibling == null) { + parent.prependChild(colorAttribute) + } else { + sibling.insertAfter(colorAttribute) + } + + return 1 + } + + // Closing tag simply needs to be unlinked as it only acts as a marker for where the colored text should end + if (isClosingTag(colorNode)) { + colorNode.unlink() + return 1 + } + + return 0 + } + + private fun isBraced(node: Node?): Boolean { + val previous = node?.previous as? Text ?: return false + val next = node.next as? Text ?: return false + return previous.literal == "{" && next.literal == "}" + } + + private fun isOpeningTag(node: Node?): Boolean { + return (node as? Text)?.literal?.startsWith(openingTag) == true && isBraced(node) + } + + private fun isClosingTag(node: Node?): Boolean { + return (node as? Text)?.literal == closingTag && isBraced(node) + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Cursor.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Cursor.kt new file mode 100644 index 0000000..fe53cea --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Cursor.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.selection + +import gg.essential.elementa.components.UIBlock +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.EssentialMarkdown +import gg.essential.gui.elementa.essentialmarkdown.drawables.Drawable +import gg.essential.universal.UMatrixStack +import java.awt.Color + +abstract class Cursor(val target: T) { + protected open val xBase = target.x + protected open val yBase = target.y + protected val height = target.height.toDouble() + protected val width = height / 9.0 + + @Deprecated(UMatrixStack.Compat.DEPRECATED, ReplaceWith("draw(matrixStack, state)")) + fun draw(state: DrawState) = draw(UMatrixStack(), state) + + fun draw(matrixStack: UMatrixStack, state: DrawState) { + if (!EssentialMarkdown.DEBUG) + return + + UIBlock.drawBlockSized( + matrixStack, + Color.RED, + (xBase + state.xShift).toDouble(), + (yBase + state.yShift).toDouble(), + width, + height + ) + } + + abstract operator fun compareTo(other: Cursor<*>): Int +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/ImageCursor.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/ImageCursor.kt new file mode 100644 index 0000000..2a088ad --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/ImageCursor.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.selection + +import gg.essential.gui.elementa.essentialmarkdown.drawables.ImageDrawable + +class ImageCursor(target: ImageDrawable) : Cursor(target) { + override fun compareTo(other: Cursor<*>): Int { + if (other !is ImageCursor) { + return target.y.compareTo(other.target.y).let { + if (it == 0) target.x.compareTo(other.target.x) else it + } + } + + return if (target.url == other.target.url) return 0 else 1 + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Selection.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Selection.kt new file mode 100644 index 0000000..9576c15 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/Selection.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.selection + +import gg.essential.gui.elementa.essentialmarkdown.DrawState +import gg.essential.gui.elementa.essentialmarkdown.drawables.Drawable +import gg.essential.gui.elementa.essentialmarkdown.drawables.ImageDrawable +import gg.essential.gui.elementa.essentialmarkdown.drawables.TextDrawable +import gg.essential.universal.UMatrixStack + +class Selection private constructor(val start: Cursor<*>, val end: Cursor<*>) { + val drawables = mutableListOf() + + init { + if (start.target == end.target) { + drawables.add(start.target) + val (cursorStart, cursorEnd) = if (start is TextCursor) { + start.offset to (end as TextCursor).offset + } else -1 to -1 + + setSelected(start.target, cursorStart, cursorEnd, selected = true) + } else { + // Configure selection area for the starting target + val (cursorStart, cursorEnd) = if (start is TextCursor) { + start.offset to start.target.plainText().length + } else -1 to -1 + setSelected(start.target, cursorStart, cursorEnd, selected = true) + + // We now have to iterate the entire markdown tree structure. + var currentTarget: Drawable? = start.target + + loop@ while (currentTarget != null) { + drawables.add(currentTarget) + currentTarget = nextTarget(currentTarget) + + when (currentTarget) { + null -> throw IllegalStateException() + end.target -> { + drawables.add(currentTarget) + val (cursorStart, cursorEnd) = if (end is TextCursor) { + 0 to end.offset + } else -1 to -1 + setSelected(currentTarget, cursorStart, cursorEnd, selected = true) + break@loop + } + else -> { + val (cursorStart, cursorEnd) = if (currentTarget is TextDrawable) { + 0 to currentTarget.plainText().length + } else -1 to -1 + setSelected(currentTarget, cursorStart, cursorEnd, selected = true) + } + } + } + } + } + + @Deprecated(UMatrixStack.Compat.DEPRECATED, ReplaceWith("draw(matrixStack, state)")) + fun draw(state: DrawState) = draw(UMatrixStack(), state) + + fun draw(matrixStack: UMatrixStack, state: DrawState) { + start.draw(matrixStack, state) + end.draw(matrixStack, state) + } + + fun remove() { + drawables.forEach { + setSelected(it, -1, -1, selected = false) + } + } + + private fun nextTarget(drawable: Drawable): Drawable? { + var nextTarget: Drawable? = drawable.next + while (nextTarget != null && nextTarget !is TextDrawable && nextTarget !is ImageDrawable) + nextTarget = nextTarget.next + + if (nextTarget is TextDrawable || nextTarget is ImageDrawable) + return nextTarget + + var nextContainer: Drawable = drawable.parent ?: return null + while (nextContainer.next == null) { + if (nextContainer.parent == null) + return null + nextContainer = nextContainer.parent!! + } + + return firstTargetChild(nextContainer.next!!) + } + + private fun firstTargetChild(drawable: Drawable): Drawable? { + if (drawable is TextDrawable || drawable is ImageDrawable) + return drawable + + if (drawable.children.isEmpty()) + return null + + return firstTargetChild(drawable.children.first()) + } + + private fun setSelected( + drawable: Drawable, + start: Int, + end: Int, + selected: Boolean + ) { + when (drawable) { + is TextDrawable -> { + drawable.selectionStart = start + drawable.selectionEnd = end + } + is ImageDrawable -> drawable.selected = selected + else -> throw IllegalArgumentException() + } + } + + companion object { + fun fromCursors(first: Cursor<*>, second: Cursor<*>): Selection { + return if (first <= second) Selection(first, second) else Selection(second, first) + } + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/TextCursor.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/TextCursor.kt new file mode 100644 index 0000000..6e62856 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/essentialmarkdown/selection/TextCursor.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.essentialmarkdown.selection + +import gg.essential.elementa.dsl.width +import gg.essential.gui.elementa.essentialmarkdown.drawables.TextDrawable + +/** + * A simple class which points to a position in a TextDrawable. + */ +class TextCursor(target: TextDrawable, val offset: Int) : Cursor(target) { + override val xBase = target.x + + target.formattedText.substring(0, offset + target.style.numFormattingChars).width(target.scaleModifier) + override val yBase = target.y + + override operator fun compareTo(other: Cursor<*>): Int { + if (other !is TextCursor) { + return target.y.compareTo(other.target.y).let { + if (it == 0) target.x.compareTo(other.target.x) else it + } + } + + if (target == other.target) + return offset.compareTo(other.offset) + + if (target.y == other.target.y) + return target.x.compareTo(other.target.x) + + return target.y.compareTo(other.target.y) + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/transitions/FadeInTransition.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/transitions/FadeInTransition.kt new file mode 100644 index 0000000..ed254f2 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/elementa/transitions/FadeInTransition.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.elementa.transitions + +import gg.essential.elementa.constraints.animation.AnimatingConstraints +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.transitions.BoundTransition +import gg.essential.gui.elementa.effects.AlphaEffect +import kotlin.properties.Delegates + +/** + * Fades a component and all of its children in. This is done using + * [AlphaEffect]. When the transition is finished, the effect is removed. + */ +class FadeInTransition @JvmOverloads constructor( + private val time: Float = 1f, + private val animationType: Animations = Animations.OUT_EXP, +) : BoundTransition() { + + private val alphaState = BasicState(0f) + private var alpha by Delegates.observable(0f) { _, _, newValue -> + alphaState.set(newValue) + } + + private val effect = AlphaEffect(alphaState) + + override fun beforeTransition() { + boundComponent.enableEffect(effect) + } + + override fun doTransition(constraints: AnimatingConstraints) { + constraints.setExtraDelay(time) + boundComponent.apply { + ::alpha.animate(animationType, time, 1f) + } + } + + override fun afterTransition() { + boundComponent.removeEffect(effect) + effect.cleanup() + } +} \ No newline at end of file diff --git a/gui/elementa/src/main/kotlin/gg/essential/gui/layoutdsl/gradient.kt b/gui/elementa/src/main/kotlin/gg/essential/gui/layoutdsl/gradient.kt new file mode 100644 index 0000000..a76ed05 --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/gui/layoutdsl/gradient.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.layoutdsl + +import gg.essential.elementa.effects.Effect +import gg.essential.gui.elementa.effects.GradientEffect +import gg.essential.gui.elementa.state.v2.State +import gg.essential.gui.elementa.state.v2.stateOf +import java.awt.Color + +fun Modifier.gradient(top: Color, bottom: Color, _desc: GradientVertDesc = GradientDesc) = gradient(stateOf(top), stateOf(bottom), _desc) +fun Modifier.gradient(left: Color, right: Color, _desc: GradientHorzDesc = GradientDesc) = gradient(stateOf(left), stateOf(right), _desc) + +fun Modifier.gradient(top: State, bottom: State, _desc: GradientVertDesc = GradientDesc) = gradient(top, top, bottom, bottom) +fun Modifier.gradient(left: State, right: State, _desc: GradientHorzDesc = GradientDesc) = gradient(left, right, left, right) + +sealed interface GradientVertDesc +sealed interface GradientHorzDesc +private object GradientDesc : GradientVertDesc, GradientHorzDesc + +fun Modifier.gradient( + topLeft: State, + topRight: State, + bottomLeft: State, + bottomRight: State, +) = effect { GradientEffect(topLeft, topRight, bottomLeft, bottomRight) } + +private fun Modifier.effect(effect: () -> Effect) = this then { + val instance = effect() + enableEffect(instance) + return@then { + removeEffect(instance) + } +} diff --git a/gui/elementa/src/main/kotlin/gg/essential/util/guiElementaPlatform.kt b/gui/elementa/src/main/kotlin/gg/essential/util/guiElementaPlatform.kt new file mode 100644 index 0000000..b2ed17f --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/util/guiElementaPlatform.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.util + +interface GuiElementaPlatform { + val isCoreProfile: Boolean + + fun glAlphaFunc(func: Int, ref: Float) + + companion object { + internal val platform: GuiElementaPlatform = + Class.forName(GuiElementaPlatform::class.java.name + "Impl").getConstructor().newInstance() as GuiElementaPlatform + } +} \ No newline at end of file diff --git a/gui/elementa/src/main/kotlin/gg/essential/util/strings.kt b/gui/elementa/src/main/kotlin/gg/essential/util/strings.kt new file mode 100644 index 0000000..5399f5f --- /dev/null +++ b/gui/elementa/src/main/kotlin/gg/essential/util/strings.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.util + +import java.awt.Color + +/** + * Colored strings are allowed only in markdown where allowColors is true. + * + * @return colored string using color tags (e.g. string) + */ +fun String.colored(color: Color) = "$this" \ No newline at end of file diff --git a/gui/essential/build.gradle.kts b/gui/essential/build.gradle.kts new file mode 100644 index 0000000..6cf7841 --- /dev/null +++ b/gui/essential/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +import essential.universalLibs +import gg.essential.gradle.util.KotlinVersion + +plugins { + kotlin("jvm") + id("gg.essential.defaults") +} + +universalLibs() + +dependencies { + implementation(kotlin("stdlib-jdk8", KotlinVersion.minimal.stdlib)) + implementation(project(":feature-flags")) + implementation(project(":utils")) + implementation(project(":libs")) + implementation(project(":gui:elementa")) +} + +kotlin.jvmToolchain(8) + +tasks.compileKotlin { + kotlinOptions { + moduleName = "essential" + project.path.replace(':', '-').lowercase() + } +} diff --git a/gui/essential/src/main/java/gg/essential/handlers/CertChain.java b/gui/essential/src/main/java/gg/essential/handlers/CertChain.java new file mode 100644 index 0000000..6bdc062 --- /dev/null +++ b/gui/essential/src/main/java/gg/essential/handlers/CertChain.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.handlers; + +import gg.essential.config.LoadsResources; +import kotlin.Pair; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; + +/** + * Stream-lined api for loading certificates into the default ssl context + */ +public class CertChain { + private final CertificateFactory cf = CertificateFactory.getInstance("X.509"); + private final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + + public CertChain() throws Exception { + InputStream keystoreInputStream = null; + // Skip loading built-in certificates on internal builds to spot missing certificates faster + // load the built-in certs! + Path ksPath = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts"); + keystoreInputStream = Files.newInputStream(ksPath); + keyStore.load(keystoreInputStream, null); + } + + @LoadsResources("/assets/essential/certs/%filename%.der") + public CertChain load(String filename) throws Exception { + try (InputStream cert = CertChain.class.getResourceAsStream("/assets/essential/certs/" + filename + ".der")) { + InputStream caInput = new BufferedInputStream(cert); + Certificate crt = cf.generateCertificate(caInput); + keyStore.setCertificateEntry(filename, crt); + } + return this; + } + + /** + * Some versions of the game (mainly Java 8 on Windows) don't have the correct SSL certificates. + * Therefore, we must load them manually. + *
+ * Linear: EM-1923 + * Linear: EM-2165 + */ + public CertChain loadEmbedded() throws Exception { + // Microsoft is transitioning their certificates to other root CAs because the current one expires in 2025. + // https://docs.microsoft.com/en-us/azure/security/fundamentals/tls-certificate-changes + // These are sorted alphabetically for easier comparison to assets folder + return this + .load("amazon-root-ca-1") // api.minecraftservices.com; (Unaffected but just for good measure (and so I can test these by using an empty system keystore)) + .load("baltimore-cybertrust-root") // Old root CA (in continued use) + .load("d-trust-root-class-3-ca-2-2009") // New root CA + .load("digicert-global-root-ca") // New root CA + .load("digicert-global-root-g2") // New root CA + .load("gts-root-r1") // modcore.me + .load("isrgrootx1") // Other + .load("lets-encrypt-r3") // Other + .load("microsoft-ecc-root-ca-2017") // New root CA + .load("microsoft-rsa-root-ca-2017") // New root CA + ; + } + + public Pair done() throws Exception { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); + TrustManager[] trustManagers = tmf.getTrustManagers(); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagers, null); + return new Pair<>(sslContext, trustManagers); + } +} diff --git a/gui/essential/src/main/java/gg/essential/util/APIException.java b/gui/essential/src/main/java/gg/essential/util/APIException.java new file mode 100644 index 0000000..c53739f --- /dev/null +++ b/gui/essential/src/main/java/gg/essential/util/APIException.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.util; + +import java.io.IOException; + +public class APIException extends IOException { + public APIException(String errorMessage, Throwable cause) { + super(errorMessage, cause); + } + + public APIException(String errorMessage) { + super(errorMessage); + } +} diff --git a/gui/essential/src/main/java/gg/essential/util/PlayerNotFoundException.java b/gui/essential/src/main/java/gg/essential/util/PlayerNotFoundException.java new file mode 100644 index 0000000..5f7e44b --- /dev/null +++ b/gui/essential/src/main/java/gg/essential/util/PlayerNotFoundException.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.util; + +public class PlayerNotFoundException extends Exception { + public PlayerNotFoundException(String errorMessage) { + super(errorMessage); + } +} diff --git a/gui/essential/src/main/java/gg/essential/util/RateLimitException.java b/gui/essential/src/main/java/gg/essential/util/RateLimitException.java new file mode 100644 index 0000000..afe1ddd --- /dev/null +++ b/gui/essential/src/main/java/gg/essential/util/RateLimitException.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.util; + +public class RateLimitException extends Exception { + public RateLimitException(String errorMessage) { + super(errorMessage); + } +} diff --git a/gui/essential/src/main/java/gg/essential/util/UuidNameLookup.java b/gui/essential/src/main/java/gg/essential/util/UuidNameLookup.java new file mode 100644 index 0000000..00d8b79 --- /dev/null +++ b/gui/essential/src/main/java/gg/essential/util/UuidNameLookup.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.util; + +import gg.essential.elementa.state.BasicState; +import gg.essential.gui.common.ReadOnlyState; +import gg.essential.gui.elementa.state.v2.MutableState; +import gg.essential.gui.elementa.state.v2.State; +import gg.essential.lib.gson.Gson; +import gg.essential.lib.gson.JsonElement; +import gg.essential.lib.gson.JsonObject; +import gg.essential.lib.gson.JsonParser; +import kotlinx.coroutines.Dispatchers; +import okhttp3.Request; +import okhttp3.Response; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; + +import static gg.essential.gui.elementa.state.v2.StateKt.mutableStateOf; +import static kotlinx.coroutines.ExecutorsKt.asExecutor; + +public class UuidNameLookup { + + private static final String UUID_TO_NAME_API = "https://sessionserver.mojang.com/session/minecraft/profile/"; + private static final String NAME_TO_UUID_API = "https://api.mojang.com/users/profiles/minecraft/"; + + // Stores any successful or in progress loading futures + private static final ConcurrentHashMap> uuidLoadingFutures = new ConcurrentHashMap<>(); + + // Stores any successful or in progress loading futures + private static final ConcurrentHashMap> nameLoadingFutures = new ConcurrentHashMap<>(); + + private static Profile fetchProfile(String apiAddress) throws PlayerNotFoundException, RateLimitException, IOException { + Request request = new Request.Builder().url(apiAddress).header("Content-Type", "application/json").build(); + + try(Response response = HttpUtils.getHttpClient().join().newCall(request).execute()) { + String json = response.body() != null ? response.body().string() : null; + + switch (response.code()) { + case 204: + case 404: + throw new PlayerNotFoundException("Player not found"); + case 429: + throw new RateLimitException("Rate limit exceeded"); + default: + if (json == null) { + throw new APIException("Failed to load profile: No response body"); + } + } + + JsonElement jsonElement = JsonParser.parseString(json); + if (!jsonElement.isJsonObject()) { + throw new APIException("Failed to load profile: Invalid response"); + } + + JsonObject jsonObject = jsonElement.getAsJsonObject(); + if (jsonObject.has("errorMessage")) { + throw new APIException("Failed to load profile: " + jsonObject.get("errorMessage").getAsString()); + } + + return (new Gson()).fromJson(jsonObject, Profile.class); + } + } + + public static Profile fetchProfileFromUsername(String username) throws PlayerNotFoundException, RateLimitException, IOException { + return fetchProfile(NAME_TO_UUID_API + username); + } + + public static Profile fetchProfileFromUUID(UUID uuid) throws PlayerNotFoundException, RateLimitException, IOException { + return fetchProfile(UUID_TO_NAME_API + uuid.toString().replaceAll("-", "")); + } + + public static CompletableFuture getName(UUID uuid) { + return uuidLoadingFutures.computeIfAbsent(uuid, ignored1 -> CompletableFuture.supplyAsync(() -> { + try { + Profile profile = fetchProfileFromUUID(uuid); + nameLoadingFutures.put(profile.getName().toLowerCase(Locale.ROOT), CompletableFuture.completedFuture(uuid)); + return profile.getName(); + } catch (Exception e) { + // Delete cache so we can try again next call + uuidLoadingFutures.remove(uuid); + + // Throw exception so future is completed with exception + throw new CompletionException("Failed to load name", e); + } + }, asExecutor(Dispatchers.getIO()))); + } + + public static CompletableFuture getUUID(String userName) { + return nameLoadingFutures.computeIfAbsent(userName.toLowerCase(Locale.ROOT), nameLower -> CompletableFuture.supplyAsync(() -> { + try { + Profile profile = fetchProfileFromUsername(nameLower); + UUID loadedUuid = UUID.fromString( + new StringBuilder(profile.getId()) + .insert(20, '-') + .insert(16, '-') + .insert(12, '-') + .insert(8, '-') + .toString() + ); + uuidLoadingFutures.put(loadedUuid, CompletableFuture.completedFuture(profile.getName())); + return loadedUuid; + } catch (Exception e) { + // Delete cache so we can try again next call + nameLoadingFutures.remove(nameLower); + + // Throw exception so future is completed with exception + throw new CompletionException("Failed to load UUID", e); + } + }, asExecutor(Dispatchers.getIO()))); + } + + public static void populate(String username, UUID uuid) { + uuidLoadingFutures.computeIfAbsent(uuid, k -> new CompletableFuture<>()).complete(username); + nameLoadingFutures.computeIfAbsent(username.toLowerCase(Locale.ROOT), k -> new CompletableFuture<>()).complete(uuid); + } + + @Deprecated // This uses StateV1, use `nameState` instead. + public static ReadOnlyState getNameAsState(UUID uuid) { + return getNameAsState(uuid, ""); + } + + @Deprecated // This uses StateV1, use `nameState` instead. + public static ReadOnlyState getNameAsState(UUID uuid, String initialValue) { + final BasicState state = new BasicState<>(initialValue); + getName(uuid).thenAcceptAsync(state::set, asExecutor(DispatchersKt.getClient(Dispatchers.INSTANCE))); + return new ReadOnlyState<>(state); + } + + public static State nameState(UUID uuid) { + return nameState(uuid, ""); + } + + public static State nameState(UUID uuid, String initialValue) { + MutableState state = mutableStateOf(initialValue); + getName(uuid).thenAcceptAsync(state::set, asExecutor(DispatchersKt.getClient(Dispatchers.INSTANCE))); + return state; + } + + public class Property { + private String name; + private String value; + + public String getName() { + return this.name; + } + + public String getValue() { + return this.value; + } + } + + public static class Profile { + private String id; + private String name; + private List properties; + + public String getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public List getProperties() { + return this.properties; + } + } +} diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/EssentialPalette.kt b/gui/essential/src/main/kotlin/gg/essential/gui/EssentialPalette.kt new file mode 100644 index 0000000..fe2c5e3 --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/EssentialPalette.kt @@ -0,0 +1,966 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui + +import gg.essential.elementa.components.UIImage +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.utils.withAlpha +import gg.essential.gui.image.ImageFactory +import gg.essential.gui.image.ImageGeneratorSettings +import gg.essential.gui.image.ResourceImageFactory +import java.awt.Color +import java.awt.image.BufferedImage +import java.util.concurrent.CompletableFuture + +object EssentialPalette { + + /* Messaging */ + @JvmField + val SENT_MESSAGE_TEXT: Color = Color(0xE5E5E5) + + @JvmField + val PENDING_MESSAGE_TEXT: Color = Color(0x969696) + + @JvmField + val FAILED_MESSAGE_TEXT: Color = Color(0XF5534F) + + @JvmField + val RECEIVED_MESSAGE_TEXT: Color = Color(0xE5E5E5) + + @JvmField + val SENT_MESSAGE_BACKGROUND: Color = Color(0x0A82FD) + + @JvmField + val RECEIVED_MESSAGE_BACKGROUND: Color = Color(0x333333) + + /* Status Indicator */ + @JvmField + val ONLINE: Color = Color(0x01A552) + + /* Onboarding and General Use */ + @JvmField + val ESSENTIAL_RED: Color = Color(0xFF4F51) + + @JvmField + val ESSENTIAL_BLUE: Color = Color(0x1299FF) + + @JvmField + val ESSENTIAL_YELLOW: Color = Color(0xFFEE3E) + + @JvmField + val ESSENTIAL_GOLD: Color = Color(0xFFB73E) + + @JvmField + val ESSENTIAL_GREEN: Color = Color(0x02D98E) + + @JvmField + val ESSENTIAL_PUKE_GREEN: Color = Color(0x4FCD46) + + @JvmField + val ESSENTIAL_DARK_BLUE_OR_MAYBE_PURPLE_IDK: Color = Color(0x4F37DB) + + @JvmField + val ACCENT_HOVER: Color = Color(0x00D469) + + @JvmField + val MESSAGE_UNREAD: Color = Color(0x132B1F) + + @JvmField + val MESSAGE_UNREAD_HOVER: Color = Color(0X14412a) + + @JvmField + val MENU_BACKGROUND: Color = Color(0x000000); + + @JvmField + val LIGHTEST_BACKGROUND: Color = Color(0x474747) + + @JvmField + val LIGHT_DIVIDER: Color = Color(0x303030) + + @JvmField + val LIGHT_SCROLLBAR: Color = Color(0x555555) + + @JvmField + val GREEN: Color = Color(0x2BC553) + + @JvmField + val BLACK: Color = Color(0x000000) + + @JvmField + val WHITE: Color = Color(0xFFFFFF) + + @JvmField + val BUTTON_HIGHLIGHT: Color = Color(0x474747) + + @JvmField + val BUTTON: Color = Color(0x323232) + + @JvmField + val TEXT: Color = Color(0xBFBFBF) + + @JvmField + val TEXT_HIGHLIGHT: Color = Color(0xE5E5E5) + + @JvmField + val TEXT_WARNING: Color = Color(0xCC2929) + + @JvmField + val LINK: Color = Color(0x3282F5) + + @JvmField + val LINK_HIGHLIGHT: Color = Color(0x5FA3F8) + + @JvmField + val COMPONENT_BACKGROUND: Color = Color(0x232323) + + @JvmField + val COMPONENT_BACKGROUND_HIGHLIGHT: Color = Color(0x323232) + + @JvmField + val GUI_BACKGROUND: Color = Color(0x181818) + + @JvmField + val MODAL_BACKGROUND: Color = GUI_BACKGROUND + + @JvmField + val INPUT_MODAL_BACKGROUND: Color = Color(0X1E1E1E) + + @JvmField + val MODAL_OUTLINE: Color = Color(0X3F3F3F) + + @JvmField + val MODAL_WARNING: Color = Color(0xFFAA2B) + + @JvmField + val TEXT_RED: Color = Color(0xE52222) + + @JvmField + val TEXT_BLUE: Color = Color(0x5555ff) + + @JvmField + val GRAY_OUTLINE: Color = Color(0x424242) + + @JvmField + val TEXT_SHADOW: Color = Color(0x181818) + + @JvmField + val TEXT_SHADOW_LIGHT: Color = Color(0x3F3F3F) + + @JvmField + val TEXT_DISABLED: Color = Color(0x757575) + + @JvmField + val TEXT_DARK_DISABLED: Color = Color(0x5C5C5C) + + @JvmField + val TEXT_MID_GRAY: Color = Color(0x999999) + + @JvmField + val ICON_SHADOW: Color = Color(0x333333) + + @JvmField + val SCROLLBAR: Color = Color(0x5C5C5C) + + @JvmField + val COMPONENT_HIGHLIGHT: Color = Color(0x303030) + + @JvmField + val RED: Color = Color(0xCC2929) + + @Deprecated("Originally meant for messages, but has since been abused in multiple places.") + @JvmField + val MESSAGE_SENT: Color = Color(0x0A82FD) + + @JvmField + val MESSAGE_SENT_BACKGROUND: Color = Color(0x274673) + + @JvmField + val MESSAGE_SENT_BACKGROUND_HOVER: Color = Color(0x2F5FA4) + + @JvmField + val BLUE_SHADOW: Color = Color(0x0A253F) + + @JvmField + val OTHER_BUTTON_ACTIVE: Color = Color(0x999999) + + @JvmField + val INPUT_BACKGROUND: Color = Color(0x1C1C1C) + + @Deprecated("Originally meant for messages, but has since been abused in multiple places.") + @JvmField + val MESSAGE_SENT_HOVER: Color = Color(0x4BA4FF) + + @JvmField + val TOAST_PROGRESS: Color = Color(0x999999) + + @JvmField + val TOAST_BACKGROUND: Color = Color(0x181818) + + @JvmField + val TOAST_BORDER: Color = Color(0x303030) + + @JvmField + val TOAST_BORDER_HOVER: Color = Color(0x757575) + + @JvmField + val MAIN_MENU_BLUE: Color = Color(0x2997FF) + + @JvmField + val ACCENT_BLUE: Color = Color(0x2997FF) + + @JvmField + val LOCKED_ORANGE: Color = Color(0xFA7C07) + + @JvmField + val INFO_ELEMENT_UNHOVERED: Color = Color(0x999999) + + @JvmField + val ITEM_PINNED: Color = Color(0x0A82FD) + + @JvmField + val PINNED_COMPONENT_BACKGROUND: Color = Color(0x111E30) + + @JvmField + val COMPONENT_SELECTED: Color = Color(0x121E30) + + @JvmField + val COMPONENT_SELECTED_HOVER: Color = Color(0x1E2A3C) + + @JvmField + val COMPONENT_SELECTED_HOVER_OUTLINE: Color = Color(0x4BA4FF) + + @JvmField + val COMPONENT_SELECTED_OUTLINE: Color = Color(0x0A82FD) + + @JvmField + val MESSAGE_HIGHLIGHT: Color = Color(0x333E49) + + @JvmField + val DARK_TRANSPARENT_BACKGROUND: Color = Color(0xD9121212.toInt(), true) + + @JvmField + val DARK_TRANSPARENT_BACKGROUND_HIGHLIGHTED: Color = Color(0xD9030C18.toInt(), true) + + @JvmField + val CART_ACTIVE: Color = Color(0x0A82FD) + + @JvmField + val CART_ACTIVE_HOVER: Color = Color(0x4BA4FF) + + /** Accent/Blue */ + @JvmField + val FEATURED_BLUE: Color = Color(0x0A82FD) + @JvmField + val OUTFITS_AQUA: Color = Color(0x17C7FF) + @JvmField + val SKINS_GREEN: Color = Color(0x4FE03E) + @JvmField + val EMOTES_YELLOW: Color = Color(0xFFD600) + @JvmField + val COSMETICS_ORANGE: Color = Color(0xEC8001) + + @JvmField + val COINS_BLUE: Color = Color(0x274673) + + @JvmField + val COINS_BLUE_HOVER: Color = Color(0x2F5FA4) + + @JvmField + val COINS_BLUE_BACKGROUND: Color = Color(0x1E2A3C) + + @JvmField + val COINS_BLUE_BACKGROUND_HOVER: Color = COINS_BLUE + + @JvmField + val COINS_BLUE_PRICE_BACKGROUND: Color = COINS_BLUE + + @JvmField + val COINS_BLUE_PRICE_BACKGROUND_HOVER: Color = Color(0x3073D4) + + @JvmField + val TEXT_TRANSPARENT_SHADOW: Color = Color(0, 0, 0, 127) + + /** Accent/Blue */ + @JvmField + val BANNER_BLUE: Color = Color(0x0A82FD) + /** Accent/Red */ + @JvmField + val BANNER_RED: Color = Color(0XCC2929) + /** Accent/Green */ + @JvmField + val BANNER_GREEN: Color = Color(0X2BC553) + /** Gray/500 */ + @JvmField + val BANNER_GRAY: Color = Color(0X474747) + @JvmField + val BANNER_YELLOW: Color = Color(0xFF8B20) + @JvmField + val BANNER_PURPLE: Color = Color(0x6F5CE5) + @JvmField + val BANNER_PURPLE_BACKGROUND: Color = Color(120, 86, 255).withAlpha(0.15f) + + @JvmField + val TEXT_HIGHLIGHT_BACKGROUND: Color = Color(0x507BBA) + + @JvmField + val OUTFIT_TAG: Color = Color(0X327B44) + @JvmField + val OUTFIT_TAG_SHADOW: Color = Color(0x193d23) + + /** Extended Blue/Blue Button */ + @JvmField + val BLUE_BUTTON: Color = Color(0x274673) + /** Extended Blue/Blue Button Hover Highlight */ + @JvmField + val BLUE_BUTTON_HOVER: Color = Color(0x2F5FA4) + /** Extended Blue/Blue Button Disabled */ + @JvmField + val BLUE_BUTTON_DISABLED: Color = Color(0x1E2A3C) + + /** Extended Red/Red Button Hover Shadow */ + @JvmField + val RED_BUTTON: Color = Color(0X642626) + /** Extended Red/Red Button */ + @JvmField + val RED_BUTTON_HOVER: Color = Color(0X9F4444) + + /** Extended Green/Green Button Shadow */ + @JvmField + val GREEN_BUTTON: Color = Color(0X1D4728) + /** Extended Green/Green Button */ + @JvmField + val GREEN_BUTTON_HOVER: Color = Color(0X327B44) + + /** Gray/600 */ + @JvmField + val GRAY_BUTTON: Color = Color(0X323232) + /** Gray/500 */ + @JvmField + val GRAY_BUTTON_HOVER: Color = Color(0X474747) + /** Gray/100 */ + @JvmField + val GRAY_BUTTON_HOVER_OUTLINE: Color = Color(0xBFBFBF) + + @JvmField + val YELLOW_BUTTON: Color = Color(0x734317) + @JvmField + val YELLOW_BUTTON_HOVER: Color = Color(0xA36226) + + @JvmField + val PURPLE_BUTTON: Color = Color(0x473999) + @JvmField + val PURPLE_BUTTON_HOVER: Color = Color(0x5947BF) + + /** Gray/900 */ + @JvmField + val INVALID_SCREENSHOT_TEXT: Color = Color(0x999999) + + @JvmField + val TOAST_BODY_COLOR: Color = Color(0xBFBFBF) + + @JvmField + val NEW_TOAST_PROGRESS: Color = Color(0x474747) + + @JvmField + val NEW_TOAST_PROGRESS_HOVER: Color = Color(0x5C5C5C) + + @JvmField + val BLACK_SHADOW: Color = Color.BLACK.withAlpha(0.5f) + + @JvmField + val LEGACY_ICON_YELLOW: Color = Color(0xFBFF4C) + + val LOCKED_ICON: Color = Color(0xF68822) + + val BONUS_COINS_COLOR: Color = Color(0xFDC80A) + + /** Gray/gray600 */ + @JvmField + val CHECKBOX_BACKGROUND: Color = Color(0x323232) + /** Gray/gray500 */ + @JvmField + val CHECKBOX_BACKGROUND_HOVER: Color = Color(0x474747) + /** Accent/Blue */ + @JvmField + val CHECKBOX_SELECTED_BACKGROUND: Color = Color(0x0A82FD) + /** Accent/Blue Hover */ + @JvmField + val CHECKBOX_SELECTED_BACKGROUND_HOVER: Color = Color(0x4BA4FF) + /** Gray/300 */ + @JvmField + val CHECKBOX_OUTLINE: Color = Color(0x757575) + + fun getMessageColor(hovered: State, sentByClient: Boolean): State { + return hovered.map { + if (it) { + if (sentByClient) { + MESSAGE_SENT_BACKGROUND_HOVER + } else { + BUTTON_HIGHLIGHT + } + } else { + if (sentByClient) { + MESSAGE_SENT_BACKGROUND + } else { + COMPONENT_BACKGROUND_HIGHLIGHT + } + } + } + } + + /* Utilities for colors */ + fun getTextColor(hovered: State, enabled: State): State { + return hovered.zip(enabled).map { (hovered, enabled) -> + if (enabled) { + if (hovered) { + TEXT_HIGHLIGHT + } else { + TEXT + } + } else { + TEXT_DISABLED + } + } + } + + fun getTextColor(hovered: State): State = getTextColor(hovered, BasicState(true)) + + fun getLinkColor(hovered: State, enabled: State): State { + return hovered.zip(enabled).map { (hovered, enabled) -> + if (enabled) { + if (hovered) { + GREEN + } else { + TEXT + } + } else { + TEXT_DISABLED + } + } + } + + fun getLinkColor(hovered: State): State = getLinkColor(hovered, BasicState(true)) + + fun getButtonColor(hovered: State, enabled: State): State { + return hovered.zip(enabled).map { (hovered, enabled) -> + if (enabled) { + if (hovered) { + BUTTON_HIGHLIGHT + } else { + BUTTON + } + } else { + COMPONENT_BACKGROUND + } + } + } + + fun getButtonColor(hovered: State): State = getButtonColor(hovered, BasicState(true)) + + + /* Icons */ + @JvmField + val SHOPPING_CART_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/cart.png") + + @JvmField + val SHOPPING_CART_12X: ImageFactory = ResourceImageFactory("/assets/essential/textures/cart_12x12.png") + + @JvmField + val SHOPPING_CART_8X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/cart_8x7.png") + + @JvmField + val TURN_RIGHT_18X: ImageFactory = ResourceImageFactory("/assets/essential/textures/turn_right_18x18.png") + + @JvmField + val TURN_LEFT_18X: ImageFactory = ResourceImageFactory("/assets/essential/textures/turn_left_18x18.png") + + @JvmField + val CHECKMARK_8X6: ImageFactory = ResourceImageFactory("/assets/essential/textures/checkmark_8x6.png") + + @JvmField + val CHECKMARK_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/checkmark_7x5.png") + + @JvmField + val PLUS_5X: ImageFactory = ResourceImageFactory("/assets/essential/textures/plus_5x5.png") + + @JvmField + val PLUS_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/plus_7x7.png") + + @JvmField + val PLUS_10X: ImageFactory = ResourceImageFactory("/assets/essential/textures/plus_10x10.png") + + @JvmField + val PERSON_4X6: ImageFactory = ResourceImageFactory("/assets/essential/textures/person_4x6.png") + + @JvmField + val PLUS_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/plus.png") + + @JvmField + val CANCEL_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/cancel_7x7.png") + + @JvmField + val CANCEL_10X: ImageFactory = ResourceImageFactory("/assets/essential/textures/cancel_10x10.png") + + @JvmField + val CANCEL_12X: ImageFactory = ResourceImageFactory("/assets/essential/textures/cancel_12x12.png") + + @JvmField + val CANCEL_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/cancel.png") + + @JvmField + val SEARCH_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/search.png") + + @JvmField + val SEARCH_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/search_7x.png") + + @JvmField + val BURGER_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/friends/burger.png") + + @JvmField + val KICK_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/kick.png") + + @JvmField + val FEATURED_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/featured.png") + + @JvmField + val WIP_620X: ImageFactory = ResourceImageFactory("/assets/essential/textures/wip.png") + + @JvmField + val HAT_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/hat.png") + + @JvmField + val GRID_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/grid.png") + + @JvmField + val ROW_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/row.png") + + @JvmField + val SLIDERS_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/sliders.png") + + @JvmField + val UPLOAD_SKIN_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/upload-skin.png") + + @JvmField + val PARTNER_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/partner.png") + + @JvmField + val PARTNER_SMALL_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/partner_small.png") + + @JvmField + val GIFT_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/gift.png") + + @JvmField + val SALE_20_PERCENT_25x11: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/20_percent_sale.png") + + @JvmField + val SALE_INDICATOR_28x11: ImageFactory = ResourceImageFactory("/assets/essential/textures/sale_indicator.png") + + @JvmField + val NEW_22x11: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/new_22x11.png") + + @JvmField + val LOCK_OPEN_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/lock_open.png") + + @JvmField + val LOCK_CLOSED_16x: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/lock_closed.png") + + @JvmField + val PLAY_ARROW_4x5: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/play_arrow.png") + + @JvmField + val COSMETICS_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/cosmetics.png") + + @JvmField + val COSMETICS_OFF_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/cosmetics_off_10x7.png") + + @JvmField + val COSMETICS_10X_ON: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/cosmetics_on.png") + + @JvmField + val COSMETICS_10X_OFF: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/cosmetics_off.png") + + @JvmField + val EMOTES_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/emotes.png") + + @JvmField + val ESSENTIAL_5X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/essential.png") + + @JvmField + val ESSENTIAL_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/logo.png") + + @JvmField + val FRIENDS_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/friends.png") + + @JvmField + val MC_FOLDER_8X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/mc_folder.png") + + @JvmField + val MESSAGES_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/messages.png") + + @JvmField + val MODS_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/mods.png") + + @JvmField + val PICTURES_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/pictures.png") + + @JvmField + val PICTURES_SHORT_9X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/pictures_short.png") + + @JvmField + val RADIO_TICK_5X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/radio_tick.png") + + @JvmField + val EXPAND_6X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/expand.png") + + @JvmField + val SETTINGS_9X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/settings.png") + + @JvmField + val SETTINGS_VERTICAL_10X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/settings_vertical.png") + + @JvmField + val SETTINGS_9X8: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/settings_9x8.png") + + @JvmField + val INFO_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/info_9x9.png") + + @JvmField + val NOTICE_11X: ImageFactory = ResourceImageFactory("/assets/essential/textures/notice_11x11.png") + + @JvmField + val ARROW_UP_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/arrow_up.png") + + @JvmField + val ARROW_DOWN_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/arrow_down.png") + + @JvmField + val ARROW_LEFT_4X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/arrow-left.png") + + @JvmField + val ARROW_LEFT_5X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/arrow-left_5x7.png") + + @JvmField + val ARROW_RIGHT_3X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/arrow-right_3x5.png") + + @JvmField + val ARROW_RIGHT_4X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/arrow-right.png") + + @JvmField + val ARROW_UP_RIGHT_5X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/arrow-up-right.png") + + @JvmField + val SOCIAL_10X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/social.png") + + @JvmField + val LINK_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/link.png") + + @JvmField + val HEART_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/heart.png") + + @JvmField + val HEART_7X6: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/heart_7x6.png") + + @JvmField + val TRASH_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/trash.png") + + @JvmField + val TRASH_CAN_7X11: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/trash_can_10x.png") + + @JvmField + val TRASH_CAN_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/trash_can_16x.png") + + @JvmField + val FOLDER_10X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/folder_10x.png") + + @JvmField + val FOLDER_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/folder_16x.png") + + @JvmField + val EDIT_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/edit.png") + + @JvmField + val HEART_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/favorite.png") + + @JvmField + val FILE_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/file.png") + + @JvmField + val COPY_EXISTING_LINK_16X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/link.png") + + @JvmField + val FULLSCREEN_11X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/fullscreen.png") + + @JvmField + val FULLSCREEN_10X_ON: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/fullscreen_on.png") + + @JvmField + val FULLSCREEN_10X_OFF: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/fullscreen_off.png") + + @JvmField + val BELL_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/bell.png") + + @JvmField + val NOTIFICATIONS_10X_ON: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/notifications_on.png") + + @JvmField + val NOTIFICATIONS_10X_OFF: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/notifications_off.png") + + @JvmField + val COPY_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/copy.png") + + @JvmField + val EDIT_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/edit.png") + + @JvmField + val FOLDER_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/folder.png") + + @JvmField + val HEART_FILLED_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/heart_filled.png").withColor(TEXT_RED) + + @JvmField + val HEART_EMPTY_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/heart_outline.png") + + @JvmField + val IMAGE_SIZE_BIG_10X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/image_size_big.png") + + @JvmField + val IMAGE_SIZE_SMALL_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/image_size_small.png") + + @JvmField + val LINK_8X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/link.png") + + @JvmField + val REDO_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/redo.png") + + @JvmField + val SAVE_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/save.png") + + @JvmField + val UNDO_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/undo.png") + + @JvmField + val UPLOAD_9X: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/upload.png") + + @JvmField + val DOWNLOAD_7x8: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/new/download.png") + + @JvmField + val OPTIONS_8X2: ImageFactory = ResourceImageFactory("/assets/essential/textures/screenshots/options.png") + + @JvmField + val ADD_TO_CART_17X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/add_to_cart.png") + + @JvmField + val REMOVE_FROM_CART_17X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/remove_from_cart.png") + + @JvmField + val ROTATE_RIGHT_7X9: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/rotate_right.png") + + @JvmField + val ROTATE_LEFT_7X9: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/rotate_left.png") + + @JvmField + val UPLOAD_SKIN_9X13: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/skin.png") + + @JvmField + val CANCEL_5X: ImageFactory = ResourceImageFactory("/assets/essential/textures/cancel_5x5.png") + + @JvmField + val ANIMATIONS_ON: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/animation_on.png") + + @JvmField + val ANIMATIONS_OFF: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/animation_off.png") + + @JvmField + val ELLIPSES_5X1: ImageFactory = ResourceImageFactory("/assets/essential/textures/ellipses_5x1.png") + + @JvmField + val ARROW_DOWN_7X4: ImageFactory = ResourceImageFactory("/assets/essential/textures/dropdown/arrow_down.png") + + @JvmField + val ARROW_UP_7X4: ImageFactory = ResourceImageFactory("/assets/essential/textures/dropdown/arrow_up.png") + + @JvmField + val PROPERTIES_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/properties.png") + + @JvmField + val POWER_BUTTON_7X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/power-button_7x7.png") + + @JvmField + val JOIN_ARROW_5X: ImageFactory = ResourceImageFactory("/assets/essential/textures/friends/join_arrow.png") + + @JvmField + val NONE: ImageFactory = ImageFactory { + UIImage(CompletableFuture.completedFuture(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))) + }.withSettings(ImageGeneratorSettings(autoSize = false)) + + @JvmField + val RETRY_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/retry_7x7.png") + + @JvmField + val INVITE_10X6: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/invite.png") + + @JvmField + val WORLD_8X: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/world_8x8.png") + + @JvmField + val PACK_128X: ImageFactory = ResourceImageFactory("/assets/essential/textures/pack_128x128.png") + + @JvmField + val ROUND_WARNING_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/round_warning.png") + + @JvmField + val EMOTE_WHEEL_5X: ImageFactory = ResourceImageFactory("/assets/essential/textures/wardrobe/emote_wheel.png") + + @JvmField + val CHARACTER_4X6: ImageFactory = ResourceImageFactory("/assets/essential/textures/wardrobe/character_4x6.png") + + @JvmField + val FILTER_6X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/filter.png") + + @JvmField + val WARDROBE_GIFT_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/wardrobe/gift.png") + + @JvmField + val LOCK_7X9: ImageFactory = ResourceImageFactory("/assets/essential/textures/studio/lock.png") + + @JvmField + val LOCK_HOLLOW_7X9: ImageFactory = ResourceImageFactory("/assets/essential/textures/lock_hollow_7x9.png") + + @JvmField + val CROWN_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/sps/crown.png") + + @JvmField + val OP_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/sps/op.png") + + @JvmField + val PINNED_8X: ImageFactory = ResourceImageFactory("/assets/essential/textures/sps/pinned.png") + + @JvmField + val UNPINNED_8X: ImageFactory = ResourceImageFactory("/assets/essential/textures/sps/unpinned.png") + + @JvmField + val REINVITE_5X: ImageFactory = ResourceImageFactory("/assets/essential/textures/sps/reinvite_5x5.png") + + @JvmField + val ENVELOPE_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/envelope_10x7.png") + + @JvmField + val ENVELOPE_9X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/envelope_9x7.png") + + @JvmField + val STAR_4X3: ImageFactory = ResourceImageFactory("/assets/essential/textures/menu/star_4x3.png") + + @JvmField + val STAR_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/wardrobe/star.png") + + @JvmField + val REPLY_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/friends/reply.png") + + @JvmField + val TOGGLE_ON: ImageFactory = ResourceImageFactory("/assets/essential/textures/toggle/on.png") + + @JvmField + val TOGGLE_OFF: ImageFactory = ResourceImageFactory("/assets/essential/textures/toggle/off.png") + + @JvmField + val BLOCK_7X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/block7x7.png") + + @JvmField + val BLOCK_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/block.png") + + @JvmField + val CUT_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/cut.png") + + @JvmField + val PASTE_10X8: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/paste.png") + + @JvmField + val EDIT_SHORT_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/edit_short.png") + + @JvmField + val LEAVE_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/leave.png") + + @JvmField + val MARK_UNREAD_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/mark_unread.png") + + @JvmField + val PENCIL_7x7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/pencil.png") + + @JvmField + val MUTE_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/mute.png") + + @JvmField + val MUTE_8X9: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/mute_new.png") + + @JvmField + val RENAME_10X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/rename.png") + + @JvmField + val REPORT_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/report.png") + + @JvmField + val UNMUTE_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/unmute.png") + + @JvmField + val UNMUTE_8X9: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/unmute_new.png") + + @JvmField + val MESSAGE_10X6: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/message.png") + + @JvmField + val REMOVE_FRIEND_10X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/remove_friend.png") + + @JvmField + val REMOVE_FRIEND_PLAYER_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/remove_friend_player.png") + + @JvmField + val JOIN_ARROW_10X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/join_arrow.png") + + @JvmField + val SOCIAL_10X6: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/social.png") + + @JvmField + val LINK_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/link.png") + + @JvmField + val COPY_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/copy.png") + + @JvmField + val FOLDER_10X7: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/folder.png") + + @JvmField + val PROPERTIES_10X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/properties.png") + + @JvmField + val ANNOUNCEMENT_ICON_8X: ImageFactory = ResourceImageFactory("/assets/essential/textures/announcement_icon.png") + + @JvmField + val REPLY_10X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/optionmenu/reply.png") + + @JvmField + val REPLY_LEFT_7X5: ImageFactory = ResourceImageFactory("/assets/essential/textures/friends/reply_left.png") + + @JvmField + val MAKE_GROUP_9X8: ImageFactory = ResourceImageFactory("/assets/essential/textures/friends/make_group.png") + + @JvmField + val DOWNLOAD_7X8: ImageFactory = ResourceImageFactory("/assets/essential/textures/download_7x8.png") + + @JvmField + val COIN_7X: ImageFactory = ResourceImageFactory("/assets/essential/textures/coin/coin_icon.png") + + @JvmField + val COIN_BUNDLE_0_999: ImageFactory = ResourceImageFactory("/assets/essential/textures/coin/coin_bundle_0_999.png") + +} diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/AutoImageSize.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/AutoImageSize.kt new file mode 100644 index 0000000..d50e58f --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/AutoImageSize.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.image.CacheableImage +import gg.essential.elementa.constraints.* +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import gg.essential.elementa.dsl.constrain +import gg.essential.universal.utils.ReleasedDynamicTexture + +/** + * Supply an instance of this class to a [UIImage] to automatically set the size of + * the component supplied in the constructor to the size of the image's texture. + * + * @param component The component to adjust the size of + * @param alwaysOverrideSize When true, the size of the component will always update to the size of the texture. When false, it will only update the size of the component if the width and height of the component appear to be unchanged form their default values + * + */ +class AutoImageSize( + private val component: UIComponent, + private val alwaysOverrideSize: Boolean = false, +) : CacheableImage { + + override fun applyTexture(texture: ReleasedDynamicTexture?) { + if (texture == null) return + val constraints = component.constraints + + if (alwaysOverrideSize || (appearsDefaultOrOverridable(constraints.width) && appearsDefaultOrOverridable(constraints.height))) { + component.constrain { + width = AutomaticImageSizeConstraint(texture.width) + height = AutomaticImageSizeConstraint(texture.height) + } + } + } + + /** + * Returns true if the supplied [constraint] appears to be unedited from its default state + */ + private fun appearsDefault(constraint: SuperConstraint): Boolean { + if (constraint !is PixelConstraint) { + return false + } + return constraint.value == 0f && !constraint.alignOpposite && !constraint.alignOutside + } + + private fun appearsDefaultOrOverridable(constraint: SuperConstraint): Boolean { + return constraint is AutomaticImageSizeConstraint || appearsDefault(constraint) + } + + override fun supply(image: CacheableImage) { + // Not implemented + } + + /** + * Effectively a PixelConstraint for Width and Height that does not have any of the extra options. + * This class exists so that we can set the width and height of an image to a fixed value while being + * able to detect the type and mark the constraint as overrideable if the image changes. + */ + class AutomaticImageSizeConstraint( + private val value: Int + ): WidthConstraint, HeightConstraint { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getHeightImpl(component: UIComponent): Float { + return value.toFloat() + } + + override fun getWidthImpl(component: UIComponent): Float { + return value.toFloat() + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + } + } + +} \ No newline at end of file diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/Checkbox.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/Checkbox.kt new file mode 100644 index 0000000..d019ca1 --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/Checkbox.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.gui.EssentialPalette +import gg.essential.gui.elementa.GuiScaleOffsetConstraint +import gg.essential.universal.USound +import gg.essential.gui.util.hoveredState +import gg.essential.vigilance.utils.onLeftClick +import java.awt.Color + +class Checkbox( + initialValue: Boolean = false, + boxColor: State = EssentialPalette.BUTTON.state(), + checkmarkColor: State = EssentialPalette.TEXT_HIGHLIGHT.state(), + checkmarkScaleOffset: Float = 0f, + private val playClickSound: Boolean = true, + private val callback: ((Boolean) -> Unit)? = null, +) : UIBlock(EssentialPalette.BUTTON) { + + val isChecked = initialValue.state() + val boxColorState = boxColor.map { it } + val checkmarkColorState = checkmarkColor.map { it } + + init { + constrain { + width = 9.pixels + height = AspectConstraint() + color = hoveredState().zip(boxColorState).map { (hovered, color) -> + if (hovered) color.brighter() else color + }.toConstraint() + } + + onLeftClick {click -> + click.stopPropagation() + toggle() + } + } + + private val checkmark by Checkmark(checkmarkScaleOffset, checkmarkColorState).constrain { + x = CenterConstraint() + y = CenterConstraint() + }.bindParent(this, isChecked) + + fun toggle() { + isChecked.set { !it } + + if (playClickSound) { + USound.playButtonPress() + } + + callback?.invoke(isChecked.get()) + } +} + +private class Checkmark(scaleOffset: Float, color: State) : UIContainer() { + init { + repeat(5) { + UIBlock(color).constrain { + x = SiblingConstraint(alignOpposite = true) + y = SiblingConstraint() + width = AspectConstraint() + height = GuiScaleOffsetConstraint(scaleOffset) + } childOf this + } + + repeat(2) { + UIBlock(color).constrain { + x = SiblingConstraint(alignOpposite = true) + y = SiblingConstraint(alignOpposite = true) + width = AspectConstraint() + height = GuiScaleOffsetConstraint(scaleOffset) + } childOf this + } + + constrain { + width = ChildBasedSizeConstraint() + height = ChildBasedMaxSizeConstraint() * 5 + } + } +} diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/CompactEssentialToggle.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/CompactEssentialToggle.kt new file mode 100644 index 0000000..a10d5f4 --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/CompactEssentialToggle.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.AspectConstraint +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.gui.EssentialPalette +import gg.essential.gui.elementa.state.v2.combinators.map +import gg.essential.gui.elementa.state.v2.toV2 +import gg.essential.gui.layoutdsl.* +import gg.essential.universal.USound +import gg.essential.util.centered +import gg.essential.gui.util.hoveredState +import gg.essential.gui.util.pollingState +import gg.essential.gui.util.stateBy +import gg.essential.vigilance.utils.onLeftClick +import java.awt.Color + +abstract class EssentialToggle( + private val enabled: State, + private val boxOffset: Int +) : UIBlock() { + protected val switchBox by UIBlock().constrain { + y = CenterConstraint() + width = AspectConstraint() + } childOf this + + init { + onLeftClick { + USound.playButtonPress() + enabled.set { !it } + } + + enabled.onSetValueAndNow { + val xConstraint = boxOffset.pixel(alignOpposite = it) + // Null during init + if (Window.ofOrNull(this@EssentialToggle) != null) { + switchBox.animate { + setXAnimation(Animations.OUT_EXP, 0.25f, xConstraint) + } + } else { + switchBox.setX(xConstraint) + } + } + } +} + +class FullEssentialToggle( + enabled: State, + backgroundColor: Color, +) : EssentialToggle(enabled, 1) { + // This component is used in Vigilance, and we use the property here + // to avoid a divergence in the toggle implementations + private val showToggleIndicators = pollingState { + System.getProperty("essential.hideSwitchIndicators") != "true" + } + + private val accentColor = enabled.map { + if (it) { + EssentialPalette.ACCENT_BLUE + } else { + EssentialPalette.TEXT + } + } + + private val onIndicator by UIContainer().constrain { + height = 100.percent + width = 50.percent + }.addChild { + EssentialPalette.TOGGLE_ON.withColor(backgroundColor).create().centered() + }.bindParent(this, showToggleIndicators and enabled, index = 0) + + private val offIndicator by UIContainer().constrain { + x = 0.pixels(alignOpposite = true) + height = 100.percent + width = 50.percent + }.addChild { + EssentialPalette.TOGGLE_OFF + .create() + .constrain { + color = EssentialPalette.TEXT_MID_GRAY.toConstraint() + } + .centered() + }.bindParent(this, showToggleIndicators and !enabled, index = 0) + + init { + constrain { + width = 20.pixels + height = 11.pixels + } + + setColor(accentColor.toConstraint()) + + switchBox.constrain { + height = 100.percent - 2.pixels + color = hoveredState().map { hovered -> + if (hovered) { + EssentialPalette.BUTTON + } else { + backgroundColor + } + }.toConstraint() + } + + } +} + +fun LayoutScope.compactFullEssentialToggle( + enabled: State, + modifier: Modifier = Modifier, + offColor: State = BasicState(EssentialPalette.TEXT_MID_GRAY), + onColor: State = BasicState(EssentialPalette.GREEN), + shadowColor: State = BasicState(EssentialPalette.BLACK), +) { + val color: State = stateBy { if (enabled()) onColor() else offColor() } + val coloredModifier = Modifier.color(color).hoverColor(color.map { it.brighter() }).then(shadowColor.map { if (it != null) Modifier.shadow(it) else Modifier }) + + column(Modifier.width(10f).height(6f).then(modifier)) { + box(coloredModifier.fillWidth().height(1f)) + row(Modifier.fillWidth().fillRemainingHeight()) { + box(coloredModifier.width(1f).fillHeight()) + row(Modifier.fillRemainingWidth().fillHeight(), Arrangement.SpaceBetween) { + box(coloredModifier.fillHeight().animateWidth(enabled.toV2().map {{ if (it) 50.percent else 0.percent }}, 0.25f)) + box(coloredModifier.fillHeight().animateWidth(enabled.toV2().map {{ if (it) 0.percent else 50.percent }}, 0.25f)) + } + box(coloredModifier.width(1f).fillHeight()) + } + box(coloredModifier.fillWidth().height(1f)) + }.onLeftClick { click -> + USound.playButtonPress() + enabled.set { !it } + click.stopPropagation() + } +} diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/ContextOptionMenu.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/ContextOptionMenu.kt new file mode 100644 index 0000000..7b5421a --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/ContextOptionMenu.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.ScrollComponent +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.events.UIClickEvent +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.gui.EssentialPalette +import gg.essential.gui.elementa.state.v2.* +import gg.essential.gui.image.ImageFactory +import gg.essential.gui.layoutdsl.* +import gg.essential.universal.UKeyboard +import gg.essential.universal.USound +import gg.essential.vigilance.utils.onLeftClick +import java.awt.Color + + +class ContextOptionMenu( + posX: Float, + posY: Float, + vararg options: Item, + val maxHeight: Float = Float.POSITIVE_INFINITY, +) : UIContainer() { + + private val optionColumnPadding: Float = 3f + + private val componentBackgroundColor = EssentialPalette.COMPONENT_BACKGROUND + private val componentBackgroundHighlightColor = EssentialPalette.COMPONENT_BACKGROUND_HIGHLIGHT + private val outlineColor = EssentialPalette.BUTTON_HIGHLIGHT + + private val closeActions = mutableListOf<() -> Unit>() + + // X and Y setup in init + // FIXME: Kotlin emits invalid bytecode if this is `val`, see https://youtrack.jetbrains.com/issue/KT-48757 + private var optionContainer: UIComponent + + init { + fun LayoutScope.divider() { + spacer(height = optionColumnPadding) + box(Modifier.height(1f).fillWidth().color(outlineColor)) + spacer(height = optionColumnPadding) + } + + fun LayoutScope.option(option: Option) { + + val colorModifier = Modifier + .color(option.disabled.map { if (it) EssentialPalette.TEXT_DISABLED else option.color }) + .hoverColor(option.disabled.map { if (it) EssentialPalette.TEXT_DISABLED else option.hoveredColor }) + .shadow(option.shadowColor) + .hoverShadow(option.hoveredShadowColor) + + box(Modifier.height(15f).fillWidth().color(componentBackgroundColor).hoverColor(componentBackgroundHighlightColor).hoverScope()) { + row(Modifier.fillHeight().alignBoth(Alignment.Start)) { + box(Modifier.fillHeight().width(20f)) { + icon(option.image, colorModifier) + } + text(option.textState, colorModifier, centeringContainsShadow = false) + } + }.onLeftClick { + USound.playButtonPress() + if (!option.disabled.get()) { + option.action() + } + } + + } + + fun Modifier.customOptionMenuWidth() = this then BasicWidthModifier { + basicWidthConstraint { it.children.maxOfOrNull { child -> ChildBasedSizeConstraint().getWidth(child) } ?: 1f } + 10.pixels + } + + fun Modifier.maxSiblingHeight() = this then BasicHeightModifier { + basicHeightConstraint { it.parent.children.maxOfOrNull { child -> if (child === it) 0f else child.getHeight() } ?: 1f } + } + + fun Modifier.limitHeight() = this then { + val originalHeightConstraint = constraints.height + constraints.height = originalHeightConstraint.coerceAtMost(maxHeight.pixels) + + return@then { constraints.height = originalHeightConstraint } + } + + val listState = stateOf(options.toMutableList()).toListState() + val scrollComponent: ScrollComponent + val scrollBar: UIComponent + + this.layoutAsBox(Modifier.fillParent()) { + optionContainer = box(Modifier.childBasedMaxSize(2f).color(outlineColor).shadow(Color.BLACK)) { + scrollComponent = scrollable(Modifier.limitHeight(), vertical = true) { + column(Modifier.customOptionMenuWidth().childBasedHeight(optionColumnPadding).color(componentBackgroundColor), Arrangement.spacedBy(0f, FloatPosition.CENTER)) { + forEach(listState) { + when (it) { + is Divider -> divider() + is Option -> option(it) + } + } + } + } + box(Modifier.maxSiblingHeight().width(2f).alignHorizontal(Alignment.End).alignVertical(Alignment.Center)) { + scrollBar = box(Modifier.fillWidth().color(EssentialPalette.TEXT_DISABLED)) + } + } + } + + scrollComponent.setVerticalScrollBarComponent(scrollBar, true) + + reposition(posX, posY) + + this.onMouseClick { + handleClose() + } + onKeyType { _, keyCode -> + if (keyCode == UKeyboard.KEY_ESCAPE) { + handleClose() + } + } + } + + private fun handleClose() { + for (closeAction in closeActions) { + closeAction() + } + releaseWindowFocus() + parent.removeChild(this) + } + + + fun onClose(action: () -> Unit) { + closeActions.add(action) + } + + fun init() { + grabWindowFocus() + } + + fun reposition(x: Float, y: Float) = reposition(x.pixels, y.pixels) + + fun reposition(x: XConstraint, y: YConstraint) { + optionContainer.setX(x.coerceAtMost(0.pixels(alignOpposite = true))) + optionContainer.setY(y.coerceAtMost(0.pixels(alignOpposite = true))) + } + + sealed interface Item + + object Divider : Item + + data class Option( + val textState: State, + val image: State, + val disabled: State = BasicState(false), + val color: Color = EssentialPalette.TEXT, + val hoveredColor: Color = EssentialPalette.TEXT_HIGHLIGHT, + val shadowColor: Color = EssentialPalette.BLACK, + val hoveredShadowColor: Color = shadowColor, + val action: () -> Unit, + ) : Item { + constructor( + text: String, + image: ImageFactory, + disabled: State = BasicState(false), + textColor: Color = EssentialPalette.TEXT, + hoveredColor: Color = EssentialPalette.TEXT_HIGHLIGHT, + shadowColor: Color = EssentialPalette.BLACK, + hoveredShadowColor: Color = shadowColor, + action: () -> Unit, + ) : this( + BasicState(text), + BasicState(image), + disabled, + textColor, + hoveredColor, + shadowColor, + hoveredShadowColor, + action + ) + + } + + data class Position(val xConstraint: XConstraint, val yConstraint: YConstraint) { + constructor(x: Float, y: Float) : this(x.pixels, y.pixels) + + constructor(component: UIComponent, alignOppositeX: Boolean) : this( + 0.pixels(alignOpposite = alignOppositeX) boundTo component, + (2).pixels( + alignOpposite = true, + alignOutside = true + ) boundTo component + ) + + constructor(event: UIClickEvent) : this(event.absoluteX, event.absoluteY) + } + + companion object { + + + fun create( + boundTo: UIComponent, + vararg option: Item, + maxHeight: Float = Float.POSITIVE_INFINITY, + onClose: () -> Unit = {} + ) = create( + Position(boundTo, true), + Window.of(boundTo), + option = option, + maxHeight = maxHeight, + onClose = onClose + ) + + fun create( + position: Position, + window: Window, + vararg option: Item, + maxHeight: Float = Float.POSITIVE_INFINITY, + onClose: () -> Unit = {} + ) { + val menu = ContextOptionMenu( + 0f, + 0f, + *option, + maxHeight = maxHeight, + ) childOf window + menu.reposition(position.xConstraint, position.yConstraint) + menu.init() + menu.onClose(onClose) + } + + } + +} + diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialCollapsibleSearchbar.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialCollapsibleSearchbar.kt new file mode 100644 index 0000000..6a75e3c --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialCollapsibleSearchbar.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.gui.EssentialPalette +import gg.essential.gui.common.input.UITextInput +import gg.essential.gui.common.shadow.ShadowEffect +import gg.essential.gui.common.shadow.ShadowIcon +import gg.essential.gui.elementa.state.v2.mutableStateOf +import gg.essential.gui.elementa.state.v2.toV1 +import gg.essential.universal.UKeyboard +import gg.essential.gui.util.isInComponentTree +import gg.essential.vigilance.utils.onLeftClick +import java.awt.Color + +open class EssentialSearchbar( + placeholder: String = "Search...", + placeholderColor: Color = EssentialPalette.TEXT, + initialValue: String = "", + private val activateOnSearchHokey: Boolean = true, + private val activateOnType: Boolean = true, + private val clearSearchOnEscape: Boolean = false, +) : UIContainer() { + val textContentV2 = mutableStateOf(initialValue) + val textContent = textContentV2.toV1(this) + + protected val searchContainer by UIBlock(EssentialPalette.BUTTON).constrain { + width = 100.percent + height = 100.percent + } childOf this + + private val searchIcon by ShadowIcon(EssentialPalette.SEARCH_7X, true).constrain { + x = 5.pixels + y = CenterConstraint() + }.rebindPrimaryColor(placeholderColor.state()) childOf searchContainer + + protected val searchInput: UITextInput by UITextInput(placeholder = placeholder, shadowColor = EssentialPalette.BLACK).constrain { + x = SiblingConstraint(5f) + y = CenterConstraint() + width = FillConstraint(useSiblings = false) + } childOf searchContainer + + init { + constrain { + width = 100.percent + height = 17.pixels + } + + searchContainer.onLeftClick { + activateSearch() + } + + searchInput.onKeyType { _, keyCode -> + if (keyCode == UKeyboard.KEY_ESCAPE && clearSearchOnEscape) { + textContentV2.set("") + } + } + + searchInput.placeholderColor.set(placeholderColor) + + searchInput.onUpdate { + textContentV2.set(it) + } + textContentV2.onSetValue(this) { + if (it != searchInput.getText()) { + searchInput.setText(it) + } + } + + effect(ShadowEffect(Color.BLACK)) + } + + override fun afterInitialization() { + super.afterInitialization() + + Window.of(this).onKeyType { typedChar, keyCode -> + if (!this@EssentialSearchbar.isInComponentTree()) { + return@onKeyType + } + + when { + activateOnSearchHokey && keyCode == UKeyboard.KEY_F && UKeyboard.isCtrlKeyDown() + && !UKeyboard.isShiftKeyDown() && !UKeyboard.isAltKeyDown() -> { + activateSearch() + } + activateOnType && !typedChar.isISOControl() -> { + searchInput.setActive(true) + searchInput.keyType(typedChar, keyCode) + searchInput.setActive(false) + activateSearch() + } + } + } + } + + fun setText(text: String) { + searchInput.setText(text) + textContent.set(text) + } + + fun getText(): String { + return textContent.get() + } + + fun activateSearch() { + searchInput.grabWindowFocus() + } + +} + +class EssentialCollapsibleSearchbar( + placeholder: String = "Search...", + placeholderColor: Color = EssentialPalette.TEXT, + initialValue: String = "", + activateOnSearchHokey: Boolean = true, + activateOnType: Boolean = true, + expandedWidth: Int = 95, +) : EssentialSearchbar( + placeholder, + placeholderColor, + initialValue, + activateOnSearchHokey, + activateOnType, +) { + + private val collapsed = BasicState(true) + + private val toggleIcon = collapsed.map { + if (it) { + EssentialPalette.SEARCH_7X + } else { + EssentialPalette.CANCEL_5X + } + } + + private val toggleButton by IconButton( + toggleIcon, + tooltipText = "".state(), + enabled = true.state(), + buttonText = "".state(), + iconShadow = true.state(), + textShadow = true.state(), + tooltipBelowComponent = true, + ).constrain { + x = 0.pixels(alignOpposite = true) + width = AspectConstraint() + height = 100.percent + }.onLeftClick { + collapsed.set { !it } + if (collapsed.get()) { + textContent.set("") + } else { + activateSearch() + } + } childOf this + + init { + constrain { + width = ChildBasedSizeConstraint() + } + + searchContainer.setWidth(expandedWidth.pixels).bindParent(this, !collapsed) + + // Expand on ctrl+f or type + searchInput.onFocus { expand() } + } + + fun collapse() { + collapsed.set(true) + } + + fun expand() { + collapsed.set(false) + } +} \ No newline at end of file diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialDropDown.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialDropDown.kt new file mode 100644 index 0000000..c3e9031 --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialDropDown.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.effects.ScissorEffect +import gg.essential.gui.EssentialPalette +import gg.essential.gui.elementa.lazyHeight +import gg.essential.gui.elementa.state.v2.* +import gg.essential.gui.elementa.state.v2.ListState +import gg.essential.gui.elementa.state.v2.combinators.* +import gg.essential.gui.layoutdsl.* +import gg.essential.universal.USound +import gg.essential.vigilance.utils.onLeftClick +import java.awt.Color + +class EssentialDropDown( + initialSelection: T, + private val items: ListState>, + var maxHeight: Float = Float.MAX_VALUE, + val compact: State = stateOf(false), + val disabled: State = stateOf(false), +) : UIBlock() { + + private val mutableExpandedState: MutableState = mutableStateOf(false) + + private val highlightedColor = EssentialPalette.BUTTON_HIGHLIGHT + private val componentBackgroundColor = EssentialPalette.COMPONENT_BACKGROUND + private val componentBackgroundHighlightColor = EssentialPalette.COMPONENT_BACKGROUND_HIGHLIGHT + + private val mainButtonTextColor = disabled.map { if(it) EssentialPalette.TEXT_DISABLED else EssentialPalette.TEXT } + private val mainButtonTextHoverColor = disabled.map { if(it) EssentialPalette.TEXT_DISABLED else EssentialPalette.TEXT_HIGHLIGHT } + + + private val dropdownColorState = stateBy { + when { + disabled() -> EssentialPalette.COMPONENT_BACKGROUND + mutableExpandedState() -> highlightedColor + else -> EssentialPalette.COMPONENT_BACKGROUND_HIGHLIGHT + } + }.toV1(this@EssentialDropDown) + private val dropdownHoverColorState = disabled.map { if(it) EssentialPalette.COMPONENT_BACKGROUND else highlightedColor }.toV1(this@EssentialDropDown) + + private val optionTextPadding = 5f + private val iconContainerWidth = 15f + private val maxItemWidthState = stateBy { items().maxOfOrNull { it.textState().width() + 2 * optionTextPadding + iconContainerWidth } ?: 50f } + + /** Public States **/ + val selectedOption: MutableState> = mutableStateOf(items.get().first { it.value == initialSelection }) + val expandedState: State = mutableExpandedState + + init { + + fun LayoutScope.option(option: Option) { + val colorModifier = Modifier + .color(option.color) + .hoverColor(option.hoveredColor) + .shadow(option.shadowColor) + .hoverShadow(option.hoveredShadowColor) + + row(Modifier.height(15f).fillWidth().color(componentBackgroundColor).hoverColor(componentBackgroundHighlightColor).hoverScope(), Arrangement.SpaceBetween) { + box(Modifier.childBasedWidth(optionTextPadding)) { + text(option.textState.toV1(this@EssentialDropDown), colorModifier, centeringContainsShadow = false) + } + box(Modifier.width(iconContainerWidth)) { + if_(selectedOption.map { it == option }) { + icon(EssentialPalette.CHECKMARK_7X5, colorModifier then Modifier.alignHorizontal(Alignment.Start(3f))) + } + } + }.onLeftClick { + if(disabled.get()) + return@onLeftClick + + USound.playButtonPress() + it.stopPropagation() + select(option) + } + + } + + fun Modifier.customWidth() = this then BasicWidthModifier { basicWidthConstraint { maxItemWidthState.get() } } + + fun Modifier.maxSiblingHeight() = this then BasicHeightModifier { + basicHeightConstraint { it.parent.children.maxOfOrNull { child -> if (child === it) 0f else child.getHeight() } ?: 1f } + } + + fun Modifier.limitHeight() = this then { + val originalHeightConstraint = constraints.height + + val distanceToWindowBorder = lazyHeight { + basicHeightConstraint { + // To get the remaining height we have available for the scrollbar we subtract: + // - the position of the expandedBlock (this@EssentialDropDown.getBottom()) + // - 9f for padding (4f + 5f) + // - 4f is from the outline around the scrollbar (of which we are limiting the height) + // - 5f is the actual padding to the window + Window.of(this).getHeight() - this@EssentialDropDown.getBottom() - 9f + } + } + + constraints.height = originalHeightConstraint.coerceAtMost(distanceToWindowBorder).coerceAtMost(maxHeight.pixels) + + return@then { constraints.height = originalHeightConstraint } + } + + val arrowIconState = mutableExpandedState.map { + if (it) { + EssentialPalette.ARROW_UP_7X4 + } else { + EssentialPalette.ARROW_DOWN_7X4 + } + }.toV1(this@EssentialDropDown) + + componentName = "dropdown" + + this.layout(Modifier.height(17f).whenTrue(compact, Modifier.widthAspect(1f), Modifier.customWidth())) { + column(Modifier.fillParent(), Arrangement.spacedBy(0f, FloatPosition.START), Alignment.Start) { + box(Modifier.fillParent().color(dropdownColorState).hoverColor(dropdownHoverColorState).shadow().hoverScope()) { + if_(compact) { + icon( + EssentialPalette.FILTER_6X5, + Modifier.color(EssentialPalette.TEXT).hoverColor(EssentialPalette.TEXT_HIGHLIGHT).shadow() + ) + } `else` { + text( + stateBy { selectedOption().textState() }.toV1(this@EssentialDropDown), + Modifier + .alignHorizontal(Alignment.Start(7f)) + .color(mainButtonTextColor).hoverColor(mainButtonTextHoverColor) + .shadow(EssentialPalette.TEXT_SHADOW), + centeringContainsShadow = false, + ) + icon(arrowIconState, Modifier.alignVertical(Alignment.Center(true)).alignHorizontal(Alignment.End(7f)) + .color(mainButtonTextColor).hoverColor(mainButtonTextHoverColor)) + } + }.onLeftClick { event -> + if (disabled.get()) + return@onLeftClick + + USound.playButtonPress() + event.stopPropagation() + + if (mutableExpandedState.get()) { + collapse() + } else { + expand() + } + } + + if_(compact) { + spacer(height = 2f) + } + + val heightConstraintState = mutableExpandedState.map { + if (it) { + { ChildBasedMaxSizeConstraint() + 4.pixels } + } else { + { 0.pixels } + } + } + + val heightModifierState = stateBy { + if (compact()) { + BasicHeightModifier(heightConstraintState()) + } else { + Modifier.animateHeight(heightConstraintState, 0.25f) + } + } + + floatingBox( + Modifier.whenTrue(compact, Modifier.customWidth(), Modifier.fillWidth()) + .color(highlightedColor).shadow().effect { ScissorEffect() } + .then(heightModifierState) + ) { + val scrollBar: UIComponent + val scrollComponent = scrollable(Modifier.fillWidth(padding = 2f).limitHeight(), vertical = true) { + column( + Modifier.childBasedHeight(3f).fillWidth().color(componentBackgroundColor), + Arrangement.spacedBy(0f, FloatPosition.CENTER) + ) { + forEach(items) { + option(it) + } + } + } + box(Modifier.maxSiblingHeight().width(2f).alignHorizontal(Alignment.End)) { + scrollBar = box(Modifier.fillWidth().color(EssentialPalette.TEXT_DISABLED)) + } + scrollComponent.setVerticalScrollBarComponent(scrollBar, true) + } + } + } + } + + fun select(option: Option) { + if (items.get().contains(option)) { + selectedOption.set(option) + collapse() + } + } + + fun expand() { + mutableExpandedState.set(true) + } + + fun collapse() { + mutableExpandedState.set(false) + } + + class Option( + val textState: State, + val value: T, + val color: Color = EssentialPalette.TEXT, + val hoveredColor: Color = EssentialPalette.TEXT_HIGHLIGHT, + val shadowColor: Color = EssentialPalette.BLACK, + val hoveredShadowColor: Color = shadowColor, + ) { + constructor( + text: String, + value: T, + color: Color = EssentialPalette.TEXT, + hoveredColor: Color = EssentialPalette.TEXT_HIGHLIGHT, + shadowColor: Color = EssentialPalette.BLACK, + hoveredShadowColor: Color = shadowColor, + ) : this( + stateOf(text), + value, + color, + hoveredColor, + shadowColor, + hoveredShadowColor + ) + } + +} diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialSlider.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialSlider.kt new file mode 100644 index 0000000..7a08c0f --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/EssentialSlider.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.gui.EssentialPalette +import gg.essential.universal.USound +import gg.essential.util.bindEssentialTooltip +import gg.essential.gui.util.hoveredState +import gg.essential.vigilance.utils.onLeftClick +import kotlin.math.round + +abstract class EssentialSlider( + initialValueFraction: Float +) : UIContainer() { + + private val notchWidth = 3 + + val fraction = BasicState(initialValueFraction) + private val updates = mutableListOf<(Float) -> Unit>() + + private val sliderBar by UIBlock(EssentialPalette.BUTTON_HIGHLIGHT).constrain { + width = 100.percent + height = 100.percent - 2.pixels + y = CenterConstraint() + } childOf this + + private val sliderNotch by UIBlock().constrain { + width = notchWidth.pixels + height = 100.percent + y = CenterConstraint() + x = basicXConstraint { + it.parent.getLeft() + fraction.get() * (it.parent.getWidth() - notchWidth) + } + } childOf this + + private val sliderCovered by UIBlock(EssentialPalette.ACCENT_BLUE).constrain { + height = 100.percent - 2.pixels + width = basicWidthConstraint { + sliderNotch.getLeft() - this@EssentialSlider.getLeft() + } + y = CenterConstraint() + } childOf this + + private var hoveredState: State + + init { + // Elementa's onMouseDrag does not check whether the mouse is within the component + // So we need to do that ourselves. We want to ignore any drag that does not start within + // this component + val mouseHeld = BasicState(false) + + onLeftClick { + USound.playButtonPress() + mouseHeld.set(true) + updateSlider(it.absoluteX - this@EssentialSlider.getLeft()) + it.stopPropagation() + } + onMouseRelease { + mouseHeld.set(false) + } + sliderBar.onMouseDrag { mouseX, _, _ -> + + if (mouseHeld.get()) { + updateSlider(mouseX) + } + } + hoveredState = hoveredState() or sliderNotch.hoveredState() or mouseHeld + + sliderNotch.setColor(EssentialPalette.getTextColor(hoveredState).toConstraint()) + } + + override fun afterInitialization() { + super.afterInitialization() + // Kotlin's properties set in a constructor don't have their values written to + // until after the parent object is initialized. If this was in the constructor, + // the result of reduceFractionToDisplay would not yield the expected result + sliderNotch.bindEssentialTooltip(hoveredState, fraction.map { fraction -> + reduceFractionToDisplay(fraction) + }) + } + + /** + * Updates the slider based on the mouseX position + */ + private fun updateSlider(mouseX: Float) { + val updatedValue = updateSliderValue( + ((mouseX - sliderNotch.getWidth() / 2) / (getWidth() - sliderNotch.getWidth())).coerceIn(0f..1f) + ) + fraction.set(updatedValue) + } + + abstract fun reduceFractionToDisplay(fraction: Float): String + + /** + * Allows overriding of notch position of the slider to snap to desired values + */ + open fun updateSliderValue(fraction: Float): Float { + return fraction + } +} + +class IntEssentialSlider( + private val minValue: Int, + private val maxValue: Int, + initialValue: Int +) : EssentialSlider( + (initialValue - minValue) / (maxValue - minValue).toFloat() +) { + + private val updates = mutableListOf<(Int) -> Unit>() + + private val intValue = fraction.map { + mapFractionToRange(it) + } + + private fun mapFractionToRange(fraction: Float): Int { + val range = maxValue - minValue + return (minValue + round(fraction * range)).toInt().coerceIn(minValue..maxValue) + } + + init { + intValue.onSetValue { + for (update in updates) { + update(it) + } + } + } + + fun onUpdateInt(callback: (Int) -> Unit) { + updates.add(callback) + } + + override fun reduceFractionToDisplay(fraction: Float): String { + return mapFractionToRange(fraction).toString() + } + + override fun updateSliderValue(fraction: Float): Float { + val range = maxValue - minValue + return round(fraction * range) / range + } +} + + diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/FadeEffect.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/FadeEffect.kt new file mode 100644 index 0000000..593c343 --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/FadeEffect.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.utils.withAlpha +import gg.essential.universal.UMatrixStack +import java.awt.Color + +/** + * Modifies the alpha value of an entire component tree as a whole (e.g. render the entire hierarchy to a framebuffer + * first, then render the framebuffer texture with this alpha value). + * There is no easy way to do that in OpenGL (without actually using a framebuffer, and that requires that the alpha + * values stored in there are properly maintained, which isn't currently the case with GradientComponent) so this + * implementation cheats by knowing the background color and then simply drawing it on top. The result is the same (so + * long as there is a uniform background color). + */ +class FadeEffect(val backgroundColor: State, val alpha: Float) : Effect() { + + constructor(backgroundColor: Color, alpha: Float) : this(BasicState(backgroundColor), alpha) + + override fun beforeDraw(matrixStack: UMatrixStack) { + } + + override fun afterDraw(matrixStack: UMatrixStack) { + val x = boundComponent.getLeft().toDouble() + val y = boundComponent.getTop().toDouble() + val x2 = boundComponent.getRight().toDouble() + val y2 = boundComponent.getBottom().toDouble() + + UIBlock.drawBlock(matrixStack, backgroundColor.get().withAlpha(1f - alpha), x, y, x2, y2) + } +} diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/HighlightedBlock.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/HighlightedBlock.kt new file mode 100644 index 0000000..62fe7ea --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/HighlightedBlock.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UIRoundedRectangle +import gg.essential.elementa.constraints.ChildBasedSizeConstraint +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.dsl.* +import gg.essential.elementa.events.UIClickEvent +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.toConstraint +import gg.essential.vigilance.utils.onLeftClick +import java.awt.Color + +open class HighlightedBlock( + backgroundColor: Color, + highlightColor: Color = backgroundColor, + backgroundHoverColor: Color = backgroundColor, + highlightHoverColor: Color = highlightColor, + protected val blockRadius: Float = 0f, + protected val outlineWidth: Float = 1f, + protected val clickBehavior: ClickBehavior = ClickBehavior.NONE +) : UIContainer() { + protected var clicked: Boolean = false + protected var clickAction: (UIClickEvent) -> Unit = {} + + protected val backgroundColorState: BasicState = BasicState(backgroundColor) + protected val highlightColorState: BasicState = BasicState(highlightColor) + protected val backgroundHoverColorState: BasicState = BasicState(backgroundHoverColor) + protected val highlightHoverColorState: BasicState = BasicState(highlightHoverColor) + + val parentContainer by makeComponent(highlightColorState).constrain { + width = 100.percent() + height = 100.percent() + } + + val contentContainer by makeComponent(backgroundColorState).constrain { + x = outlineWidth.pixels() + y = outlineWidth.pixels() + width = 100.percent() - (outlineWidth * 2f).pixels() + height = 100.percent() - (outlineWidth * 2f).pixels() + } childOf parentContainer + + init { + constrain { + width = 100.percent() + height = 100.percent() + } + + super.addChild(parentContainer) + + onMouseEnter { + highlight() + } + + onMouseLeave { + unhighlight() + } + + onLeftClick { + clickAction(it) + clicked = true + + if (clickBehavior == ClickBehavior.UNHIGHLIGHT) { + unhighlight() + } + } + } + + fun constrainXBasedOnChildren() = apply { + constrain { + width = ChildBasedSizeConstraint() + } + + parentContainer.constrain { + width = ChildBasedSizeConstraint() + (outlineWidth * 2f).pixels() + } + + contentContainer.constrain { + x = outlineWidth.pixels() + width = ChildBasedSizeConstraint() + } + } + + fun constrainYBasedOnChildren() = apply { + constrain { + height = ChildBasedSizeConstraint() + } + + parentContainer.constrain { + height = ChildBasedSizeConstraint() + (outlineWidth * 2f).pixels() + } + + contentContainer.constrain { + y = outlineWidth.pixels() + height = ChildBasedSizeConstraint() + } + } + + fun constrainBasedOnChildren() = apply { + constrainXBasedOnChildren() + constrainYBasedOnChildren() + } + + override fun addChild(component: UIComponent) = apply { + component childOf contentContainer + } + + fun onClick(action: (UIClickEvent) -> Unit) = apply { + clickAction = action + } + + protected open fun highlight() { + parentContainer.animate { + setColorAnimation(Animations.OUT_EXP, 0.5f, highlightHoverColorState.toConstraint()) + } + contentContainer.animate { + setColorAnimation(Animations.OUT_EXP, 0.5f, backgroundHoverColorState.toConstraint()) + } + } + + protected open fun unhighlight() { + if (clicked && clickBehavior == ClickBehavior.STAY_HIGHLIGHTED) + return + + parentContainer.animate { + setColorAnimation(Animations.OUT_EXP, 0.5f, highlightColorState.toConstraint()) + } + contentContainer.animate { + setColorAnimation(Animations.OUT_EXP, 0.5f, backgroundColorState.toConstraint()) + } + } + + private fun makeComponent(blockColor: BasicState) = if (blockRadius == 0f) { + UIBlock(blockColor) + } else UIRoundedRectangle(blockRadius).constrain { + color = blockColor.toConstraint() + } + + enum class ClickBehavior { + UNHIGHLIGHT, + STAY_HIGHLIGHTED, + NONE, + } +} diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/HoverableInfoBlock.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/HoverableInfoBlock.kt new file mode 100644 index 0000000..0e2cd41 --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/HoverableInfoBlock.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.constraints.AspectConstraint +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.gui.EssentialPalette +import gg.essential.util.bindHoverEssentialTooltip +import gg.essential.gui.util.hoveredState + +class HoverableInfoBlock(tooltip: State) : UIBlock() { + + private val hovered = hoveredState() + private val iColor = hovered.map { + if (it) EssentialPalette.TEXT_HIGHLIGHT else EssentialPalette.INFO_ELEMENT_UNHOVERED + } + + init { + constrain { + width = 9.pixels + height = AspectConstraint() + color = EssentialPalette.getButtonColor(hovered).toConstraint() + } + + bindHoverEssentialTooltip(tooltip) + } + + private val iDot by UIBlock(iColor).constrain { + x = CenterConstraint() + y = 2.pixels + width = 1.pixel + height = AspectConstraint() + } childOf this + + private val iBody by UIBlock(iColor).constrain { + x = CenterConstraint() + y = SiblingConstraint(1f) + width = 1.pixel + height = AspectConstraint(3f) + } childOf this +} \ No newline at end of file diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/IconButton.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/IconButton.kt new file mode 100644 index 0000000..c01491e --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/IconButton.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint +import gg.essential.elementa.constraints.ChildBasedSizeConstraint +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.gui.EssentialPalette +import gg.essential.gui.common.shadow.EssentialUIText +import gg.essential.gui.common.shadow.ShadowEffect +import gg.essential.gui.common.shadow.ShadowIcon +import gg.essential.gui.image.ImageFactory +import gg.essential.universal.USound +import gg.essential.util.centered +import gg.essential.gui.util.hoveredState +import gg.essential.vigilance.utils.onLeftClick +import java.awt.Color + +class IconButton( + imageFactory: State, + tooltipText: State, + enabled: State, + buttonText: State, + iconShadow: State, + textShadow: State, + tooltipBelowComponent: Boolean, + buttonShadow: Boolean = true, +) : UIBlock() { + + private val hovered = hoveredState() + + private val iconState = imageFactory.map { it } + private val iconShadowState = iconShadow.map { it } + private val iconShadowColor = BasicState(EssentialPalette.TEXT_SHADOW).map { it } + private val tooltipState = tooltipText.map { it } + private val enabledState = enabled.map { it } + private val buttonTextState = buttonText.map { it } + private val textShadowState = textShadow.map { it } + private val textShadowColor = BasicState(EssentialPalette.TEXT_SHADOW).map { it } + private val textColor = EssentialPalette.getTextColor(hovered, enabledState).map { it } + private val layoutState = BasicState(Layout.ICON_FIRST) + private val dimension: State = BasicState(Dimension.FitWithPadding(10f, 10f)) + + constructor( + imageFactory: ImageFactory, + buttonText: String = "", + tooltipText: String = "", + iconShadow: Boolean = true, + textShadow: Boolean = true, + tooltipBelowComponent: Boolean = true, + buttonShadow: Boolean = true, + ) : this( + BasicState(imageFactory), + BasicState(tooltipText), + BasicState(true), + BasicState(buttonText), + BasicState(iconShadow), + BasicState(textShadow), + tooltipBelowComponent, + buttonShadow + ) + + constructor( + imageFactory: ImageFactory, + buttonText: State + ) : this( + BasicState(imageFactory), + BasicState(""), + BasicState(true), + buttonText, + BasicState(true), + BasicState(true), + true, + true + ) + + private val content by UIContainer().centered().constrain { + width = ChildBasedSizeConstraint() + height = ChildBasedMaxSizeConstraint() + } childOf this + + + private val icon by ShadowIcon(iconState, iconShadowState).constrain { + //x constraint set in init + y = CenterConstraint() + }.rebindPrimaryColor(EssentialPalette.getTextColor(hovered, enabledState)).rebindShadowColor(iconShadowColor) childOf content + + private val tooltip = EssentialTooltip( + this, + position = if (tooltipBelowComponent) EssentialTooltip.Position.BELOW else EssentialTooltip.Position.ABOVE, + ).constrain { + x = CenterConstraint() boundTo this@IconButton coerceAtMost 4.pixels(alignOpposite = true) + y = SiblingConstraint(5f, alignOpposite = !tooltipBelowComponent) boundTo this@IconButton + }.bindVisibility(hovered and !tooltipState.empty()) as EssentialTooltip + + private val buttonText by EssentialUIText(centeringContainsShadow = false).bindText(buttonTextState) + .bindShadow(textShadowState) + .bindShadowColor(textShadowColor.map { it }) + .setColor(textColor.toConstraint()) + .bindConstraints(layoutState) { + y = CenterConstraint() + x = when (it) { + Layout.ICON_FIRST -> SiblingConstraint(5f) + Layout.TEXT_FIRST -> 0.pixels + } + }.bindParent(content, !buttonTextState.empty()) + + init { + setColor(EssentialPalette.getButtonColor(hovered, enabledState).toConstraint()) + + bindConstraints(dimension) { + when (it) { + is Dimension.FitWithPadding -> { + width = ChildBasedSizeConstraint() + it.widthPadding.pixels + height = ChildBasedSizeConstraint() + it.heightPadding.pixels + } + is Dimension.Fixed -> { + width = it.width.pixels + height = it.height.pixels + } + } + } + + onLeftClick { + if (enabledState.get()) { + USound.playButtonPress() + it.stopPropagation() + } else { + // If the button is disabled we don't want events to be passed to parent components or sibling listeners + it.stopImmediatePropagation() + } + } + + layoutState.zip(buttonTextState.empty()).onSetValueAndNow { (layout, emptyText) -> + icon.setX( + if (emptyText) { + CenterConstraint() + } else { + when (layout) { + Layout.ICON_FIRST -> 0.pixels + Layout.TEXT_FIRST -> SiblingConstraint(6f) boundTo this@IconButton.buttonText + } + } + ) + } + + tooltip.bindLine(tooltipState) + + if (buttonShadow) { + effect(ShadowEffect(Color.BLACK)) + } + } + + fun rebindIcon(imageFactory: State): IconButton { + iconState.rebind(imageFactory) + return this + } + + fun rebindTooltipText(tooltipText: State): IconButton { + tooltipState.rebind(tooltipText) + return this + } + + fun rebindEnabled(enabled: State): IconButton { + enabledState.rebind(enabled) + return this + } + + fun rebindIconColor(color: State): IconButton { + icon.rebindPrimaryColor(color) + return this + } + + fun rebindTextColor(color: State): IconButton { + textColor.rebind(color) + return this + } + + fun setLayout(layout: Layout): IconButton { + layoutState.set(layout) + return this + } + + fun setDimension(dimension: Dimension): IconButton { + this.dimension.set(dimension) + return this + } + + + fun onActiveClick(action: () -> Unit): IconButton { + onLeftClick { + if (enabledState.get()) { + action() + } + } + return this + } + + sealed class Dimension { + data class FitWithPadding(val widthPadding: Float, val heightPadding: Float) : Dimension() + data class Fixed(val width: Float, val height: Float) : Dimension() + } + + enum class Layout { + + ICON_FIRST, + TEXT_FIRST, + + } + +} \ No newline at end of file diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/ImageLoadCallback.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/ImageLoadCallback.kt new file mode 100644 index 0000000..539edcd --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/ImageLoadCallback.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.components.image.CacheableImage +import gg.essential.universal.utils.ReleasedDynamicTexture + +class ImageLoadCallback(private val action: ReleasedDynamicTexture.() -> Unit) : CacheableImage { + override fun applyTexture(texture: ReleasedDynamicTexture?) { + texture?.action() + } + + override fun supply(image: CacheableImage) { + // No impl + } +} \ No newline at end of file diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/LoadingIcon.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/LoadingIcon.kt new file mode 100644 index 0000000..7f557de --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/LoadingIcon.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.dsl.pixels +import gg.essential.universal.UMatrixStack +import java.awt.Color + +class LoadingIcon(val scale: Double) : UIComponent() { + private var time = 0f + + init { + setX(CenterConstraint()) + setY(CenterConstraint()) + setWidth((7 * scale).pixels) + setHeight((7 * scale).pixels) + } + + override fun animationFrame() { + super.animationFrame() + + time += 1f / Window.of(this).animationFPS + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + draw(matrixStack, (getLeft() + getRight()) / 2, (getTop() + getBottom()) / 2, scale, time, getColor()) + + super.draw(matrixStack) + } + + companion object { + const val TIME_PER_FRAME = 0.12f + + private val frames = listOf( + " ", " ", " ", " X ", " X ", " X ", " X ", " ", + " ", " ", " X ", " XXX ", " XXX ", " XXX ", " X X ", " ", + " ", " X ", " XXX ", " XXXXX ", " XXXXX ", " XX XX ", " X X ", " ", + " X ", " XXX ", " XXXXX ", "XXXXXXX", "XXX XXX", "XX XX", "X X", " ", + " ", " X ", " XXX ", " XXXXX ", " XXXXX ", " XX XX ", " X X ", " ", + " ", " ", " X ", " XXX ", " XXX ", " XXX ", " X X ", " ", + " ", " ", " ", " X ", " X ", " X ", " X ", " ", + ) + const val FRAMES = 8 + + fun draw(matrixStack: UMatrixStack, xCenter: Float, yCenter: Float, scale: Double, time: Float, color: Color) { + val x0 = xCenter - 3.5 * scale + val y0 = yCenter - 3.5 * scale + val frame = (time / TIME_PER_FRAME).toInt() % FRAMES + for (i in 0..6) { + for (j in 0..6) { + if (frames[j * FRAMES + frame][i] == 'X') { + UIBlock.drawBlockSized(matrixStack, color, x0 + i * scale, y0 + j * scale, scale, scale) + } + } + } + } + } +} \ No newline at end of file diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/MenuButton.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/MenuButton.kt new file mode 100644 index 0000000..048eb1e --- /dev/null +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/MenuButton.kt @@ -0,0 +1,768 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.effects.OutlineEffect +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.elementa.utils.getStringSplitToWidthTruncated +import gg.essential.elementa.utils.withAlpha +import gg.essential.gui.EssentialPalette +import gg.essential.gui.common.MenuButton.Alignment +import gg.essential.gui.common.MenuButton.Alignment.* +import gg.essential.gui.common.MenuButton.Companion.DARK_GRAY +import gg.essential.gui.common.MenuButton.Companion.GRAY +import gg.essential.gui.common.MenuButton.Style +import gg.essential.gui.common.shadow.EssentialUIText +import gg.essential.gui.common.shadow.ShadowIcon +import gg.essential.gui.elementa.state.v2.combinators.map +import gg.essential.gui.elementa.state.v2.combinators.zip +import gg.essential.gui.image.ImageFactory +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.USound +import gg.essential.universal.shader.BlendState +import gg.essential.util.ButtonTextureProvider +import gg.essential.util.GuiEssentialPlatform.Companion.platform +import gg.essential.util.UIdentifier +import gg.essential.gui.util.hoveredState +import gg.essential.util.image.bitmap.Bitmap +import gg.essential.util.image.bitmap.bitmapState +import gg.essential.util.image.bitmap.bitmapStateIf +import gg.essential.util.image.bitmap.cropped +import gg.essential.gui.util.pollingState +import gg.essential.vigilance.utils.onLeftClick +import org.lwjgl.opengl.GL11 +import java.awt.Color + +/** + * A styled button for use in various menus. + * + * @constructor Creates a collapsable styled button containing the specified text and optional icon. + * @param buttonText The button's text as a [String] [] [State]. + * @param defaultStyle The button's normal (non-hovered) [Style] [] [State]. Defaults to [DARK_GRAY]. + * @param hoverStyle The button's [Style] [] [State] when hovered. Defaults to [GRAY]. + * @param textAlignment The [Alignment] of the button's text. Defaults to [Alignment.CENTER]. + * @param textXOffset The x offset to apply to the button's text. + * @param collapsedText The optional text as a [String] [] [State] to display when the button is collapsed. + * @param truncate Whether the text should be truncated to fit within the button. Defaults to False. + * @param clickSound Whether the button should play a click sound when pressed. Defaults to True. + * @param shouldBeRetextured Whether the button should be retextured. Defaults to null, this means it will only be retextured if it's on the main menu. + * @param action The optional callback to invoke when the button is pressed. + */ +class MenuButton @JvmOverloads constructor( + private val buttonText: State, + defaultStyle: State