diff options
Diffstat (limited to 'src/main/kotlin/features')
69 files changed, 6223 insertions, 0 deletions
diff --git a/src/main/kotlin/features/FeatureManager.kt b/src/main/kotlin/features/FeatureManager.kt new file mode 100644 index 0000000..19b91de --- /dev/null +++ b/src/main/kotlin/features/FeatureManager.kt @@ -0,0 +1,120 @@ + + +package moe.nea.firmament.features + +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import moe.nea.firmament.Firmament +import moe.nea.firmament.annotations.generated.AllSubscriptions +import moe.nea.firmament.events.FeaturesInitializedEvent +import moe.nea.firmament.events.FirmamentEvent +import moe.nea.firmament.events.subscription.Subscription +import moe.nea.firmament.features.chat.AutoCompletions +import moe.nea.firmament.features.chat.ChatLinks +import moe.nea.firmament.features.chat.QuickCommands +import moe.nea.firmament.features.debug.DebugView +import moe.nea.firmament.features.debug.DeveloperFeatures +import moe.nea.firmament.features.debug.MinorTrolling +import moe.nea.firmament.features.debug.PowerUserTools +import moe.nea.firmament.features.diana.DianaWaypoints +import moe.nea.firmament.features.events.anniversity.AnniversaryFeatures +import moe.nea.firmament.features.events.carnival.CarnivalFeatures +import moe.nea.firmament.features.fixes.CompatibliltyFeatures +import moe.nea.firmament.features.fixes.Fixes +import moe.nea.firmament.features.inventory.CraftingOverlay +import moe.nea.firmament.features.inventory.ItemRarityCosmetics +import moe.nea.firmament.features.inventory.PriceData +import moe.nea.firmament.features.inventory.SaveCursorPosition +import moe.nea.firmament.features.inventory.SlotLocking +import moe.nea.firmament.features.inventory.buttons.InventoryButtons +import moe.nea.firmament.features.inventory.storageoverlay.StorageOverlay +import moe.nea.firmament.features.mining.PickaxeAbility +import moe.nea.firmament.features.mining.PristineProfitTracker +import moe.nea.firmament.features.texturepack.CustomSkyBlockTextures +import moe.nea.firmament.features.world.FairySouls +import moe.nea.firmament.features.world.Waypoints +import moe.nea.firmament.util.data.DataHolder + +object FeatureManager : DataHolder<FeatureManager.Config>(serializer(), "features", ::Config) { + @Serializable + data class Config( + val enabledFeatures: MutableMap<String, Boolean> = mutableMapOf() + ) + + private val features = mutableMapOf<String, FirmamentFeature>() + + val allFeatures: Collection<FirmamentFeature> get() = features.values + + private var hasAutoloaded = false + + init { + autoload() + } + + fun autoload() { + synchronized(this) { + if (hasAutoloaded) return + loadFeature(MinorTrolling) + loadFeature(FairySouls) + loadFeature(AutoCompletions) + // TODO: loadFeature(FishingWarning) + loadFeature(SlotLocking) + loadFeature(StorageOverlay) + loadFeature(PristineProfitTracker) + loadFeature(CraftingOverlay) + loadFeature(PowerUserTools) + loadFeature(Waypoints) + loadFeature(ChatLinks) + loadFeature(InventoryButtons) + loadFeature(CompatibliltyFeatures) + loadFeature(AnniversaryFeatures) + loadFeature(QuickCommands) + loadFeature(SaveCursorPosition) + loadFeature(CustomSkyBlockTextures) + loadFeature(PriceData) + loadFeature(Fixes) + loadFeature(DianaWaypoints) + loadFeature(ItemRarityCosmetics) + loadFeature(PickaxeAbility) + loadFeature(CarnivalFeatures) + if (Firmament.DEBUG) { + loadFeature(DeveloperFeatures) + loadFeature(DebugView) + } + allFeatures.forEach { it.config } + FeaturesInitializedEvent.publish(FeaturesInitializedEvent(allFeatures.toList())) + hasAutoloaded = true + } + } + + fun subscribeEvents() { + AllSubscriptions.provideSubscriptions { + subscribeSingleEvent(it) + } + } + + private fun <T : FirmamentEvent> subscribeSingleEvent(it: Subscription<T>) { + it.eventBus.subscribe(false, it.invoke) + } + + fun loadFeature(feature: FirmamentFeature) { + synchronized(features) { + if (feature.identifier in features) { + Firmament.logger.error("Double registering feature ${feature.identifier}. Ignoring second instance $feature") + return + } + features[feature.identifier] = feature + feature.onLoad() + } + } + + fun isEnabled(identifier: String): Boolean? = + data.enabledFeatures[identifier] + + + fun setEnabled(identifier: String, value: Boolean) { + data.enabledFeatures[identifier] = value + markDirty() + } + +} diff --git a/src/main/kotlin/features/FirmamentFeature.kt b/src/main/kotlin/features/FirmamentFeature.kt new file mode 100644 index 0000000..2cfc4fd --- /dev/null +++ b/src/main/kotlin/features/FirmamentFeature.kt @@ -0,0 +1,23 @@ + + +package moe.nea.firmament.features + +import moe.nea.firmament.events.subscription.SubscriptionOwner +import moe.nea.firmament.gui.config.ManagedConfig + +// TODO: remove this entire feature system and revamp config +interface FirmamentFeature : SubscriptionOwner { + val identifier: String + val defaultEnabled: Boolean + get() = true + var isEnabled: Boolean + get() = FeatureManager.isEnabled(identifier) ?: defaultEnabled + set(value) { + FeatureManager.setEnabled(identifier, value) + } + override val delegateFeature: FirmamentFeature + get() = this + val config: ManagedConfig? get() = null + fun onLoad() {} + +} diff --git a/src/main/kotlin/features/chat/AutoCompletions.kt b/src/main/kotlin/features/chat/AutoCompletions.kt new file mode 100644 index 0000000..9144898 --- /dev/null +++ b/src/main/kotlin/features/chat/AutoCompletions.kt @@ -0,0 +1,57 @@ + + +package moe.nea.firmament.features.chat + +import com.mojang.brigadier.arguments.StringArgumentType.string +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.commands.get +import moe.nea.firmament.commands.suggestsList +import moe.nea.firmament.commands.thenArgument +import moe.nea.firmament.commands.thenExecute +import moe.nea.firmament.events.CommandEvent +import moe.nea.firmament.events.MaskCommands +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.repo.RepoManager +import moe.nea.firmament.util.MC + +object AutoCompletions : FirmamentFeature { + + object TConfig : ManagedConfig(identifier) { + val provideWarpTabCompletion by toggle("warp-complete") { true } + val replaceWarpIsByWarpIsland by toggle("warp-is") { true } + } + + override val config: ManagedConfig? + get() = TConfig + override val identifier: String + get() = "auto-completions" + + @Subscribe + fun onMaskCommands(event: MaskCommands) { + if (TConfig.provideWarpTabCompletion) { + event.mask("warp") + } + } + + @Subscribe + fun onCommandEvent(event: CommandEvent) { + if (!TConfig.provideWarpTabCompletion) return + event.deleteCommand("warp") + event.register("warp") { + thenArgument("to", string()) { toArg -> + suggestsList { + RepoManager.neuRepo.constants?.islands?.warps?.flatMap { listOf(it.warp) + it.aliases } ?: listOf() + } + thenExecute { + val warpName = get(toArg) + if (warpName == "is" && TConfig.replaceWarpIsByWarpIsland) { + MC.sendServerCommand("warp island") + } else { + MC.sendServerCommand("warp $warpName") + } + } + } + } + } +} diff --git a/src/main/kotlin/features/chat/ChatLinks.kt b/src/main/kotlin/features/chat/ChatLinks.kt new file mode 100644 index 0000000..f2cb78a --- /dev/null +++ b/src/main/kotlin/features/chat/ChatLinks.kt @@ -0,0 +1,161 @@ + + +package moe.nea.firmament.features.chat + +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsChannel +import io.ktor.utils.io.jvm.javaio.toInputStream +import java.net.URL +import java.util.Collections +import moe.nea.jarvis.api.Point +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlin.math.min +import net.minecraft.client.gui.screen.ChatScreen +import net.minecraft.client.texture.NativeImage +import net.minecraft.client.texture.NativeImageBackedTexture +import net.minecraft.text.ClickEvent +import net.minecraft.text.HoverEvent +import net.minecraft.text.Style +import net.minecraft.text.Text +import net.minecraft.util.Formatting +import net.minecraft.util.Identifier +import moe.nea.firmament.Firmament +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ModifyChatEvent +import moe.nea.firmament.events.ScreenRenderPostEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.transformEachRecursively +import moe.nea.firmament.util.unformattedString + +object ChatLinks : FirmamentFeature { + override val identifier: String + get() = "chat-links" + + object TConfig : ManagedConfig(identifier) { + val enableLinks by toggle("links-enabled") { true } + val imageEnabled by toggle("image-enabled") { true } + val allowAllHosts by toggle("allow-all-hosts") { false } + val allowedHosts by string("allowed-hosts") { "cdn.discordapp.com,media.discordapp.com,media.discordapp.net,i.imgur.com" } + val actualAllowedHosts get() = allowedHosts.split(",").map { it.trim() } + val position by position("position", 16 * 20, 9 * 20) { Point(0.0, 0.0) } + } + + private fun isHostAllowed(host: String) = + TConfig.allowAllHosts || TConfig.actualAllowedHosts.any { it.equals(host, ignoreCase = true) } + + private fun isUrlAllowed(url: String) = isHostAllowed(url.removePrefix("https://").substringBefore("/")) + + override val config get() = TConfig + val urlRegex = "https://[^. ]+\\.[^ ]+(\\.?( |$))".toRegex() + + data class Image( + val texture: Identifier, + val width: Int, + val height: Int, + ) + + val imageCache: MutableMap<String, Deferred<Image?>> = + Collections.synchronizedMap(mutableMapOf<String, Deferred<Image?>>()) + + private fun tryCacheUrl(url: String) { + if (!isUrlAllowed(url)) { + return + } + if (url in imageCache) { + return + } + imageCache[url] = Firmament.coroutineScope.async { + try { + val response = Firmament.httpClient.get(URL(url)) + if (response.status.value == 200) { + val inputStream = response.bodyAsChannel().toInputStream(Firmament.globalJob) + val image = NativeImage.read(inputStream) + val texture = MC.textureManager.registerDynamicTexture( + "dynamic_image_preview", + NativeImageBackedTexture(image) + ) + Image(texture, image.width, image.height) + } else + null + } catch (exc: Exception) { + exc.printStackTrace() + null + } + } + } + + val imageExtensions = listOf("jpg", "png", "gif", "jpeg") + fun isImageUrl(url: String): Boolean { + return (url.substringAfterLast('.').lowercase() in imageExtensions) + } + + @Subscribe + @OptIn(ExperimentalCoroutinesApi::class) + fun onRender(it: ScreenRenderPostEvent) { + if (!TConfig.imageEnabled) return + if (it.screen !is ChatScreen) return + val hoveredComponent = + MC.inGameHud.chatHud.getTextStyleAt(it.mouseX.toDouble(), it.mouseY.toDouble()) ?: return + val hoverEvent = hoveredComponent.hoverEvent ?: return + val value = hoverEvent.getValue(HoverEvent.Action.SHOW_TEXT) ?: return + val url = urlRegex.matchEntire(value.unformattedString)?.groupValues?.get(0) ?: return + if (!isImageUrl(url)) return + val imageFuture = imageCache[url] ?: return + if (!imageFuture.isCompleted) return + val image = imageFuture.getCompleted() ?: return + it.drawContext.matrices.push() + val pos = TConfig.position + pos.applyTransformations(it.drawContext.matrices) + val scale = min(1F, min((9 * 20F) / image.height, (16 * 20F) / image.width)) + it.drawContext.matrices.scale(scale, scale, 1F) + it.drawContext.drawTexture( + image.texture, + 0, + 0, + 1F, + 1F, + image.width, + image.height, + image.width, + image.height, + ) + it.drawContext.matrices.pop() + } + + @Subscribe + fun onModifyChat(it: ModifyChatEvent) { + if (!TConfig.enableLinks) return + it.replaceWith = it.replaceWith.transformEachRecursively { child -> + val text = child.string + if ("://" !in text) return@transformEachRecursively child + val s = Text.empty().setStyle(child.style) + var index = 0 + while (index < text.length) { + val nextMatch = urlRegex.find(text, index) + if (nextMatch == null) { + s.append(Text.literal(text.substring(index, text.length))) + break + } + val range = nextMatch.groups[0]!!.range + val url = nextMatch.groupValues[0] + s.append(Text.literal(text.substring(index, range.first))) + s.append( + Text.literal(url).setStyle( + Style.EMPTY.withUnderline(true).withColor( + Formatting.AQUA + ).withHoverEvent(HoverEvent(HoverEvent.Action.SHOW_TEXT, Text.literal(url))) + .withClickEvent(ClickEvent(ClickEvent.Action.OPEN_URL, url)) + ) + ) + if (isImageUrl(url)) + tryCacheUrl(url) + index = range.last + 1 + } + s + } + } +} diff --git a/src/main/kotlin/features/chat/QuickCommands.kt b/src/main/kotlin/features/chat/QuickCommands.kt new file mode 100644 index 0000000..5944b92 --- /dev/null +++ b/src/main/kotlin/features/chat/QuickCommands.kt @@ -0,0 +1,100 @@ + + +package moe.nea.firmament.features.chat + +import com.mojang.brigadier.context.CommandContext +import net.minecraft.text.Text +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.commands.DefaultSource +import moe.nea.firmament.commands.RestArgumentType +import moe.nea.firmament.commands.get +import moe.nea.firmament.commands.thenArgument +import moe.nea.firmament.commands.thenExecute +import moe.nea.firmament.events.CommandEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SBData + +object QuickCommands : FirmamentFeature { + override val identifier: String + get() = "quick-commands" + + fun removePartialPrefix(text: String, prefix: String): String? { + var lf: String? = null + for (i in 1..prefix.length) { + if (text.startsWith(prefix.substring(0, i))) { + lf = text.substring(i) + } + } + return lf + } + + val kuudraLevelNames = listOf("NORMAL", "HOT", "BURNING", "FIERY", "INFERNAL") + val dungeonLevelNames = listOf("ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN") + + @Subscribe + fun onCommands(it: CommandEvent) { + it.register("join") { + thenArgument("what", RestArgumentType) { what -> + thenExecute { + val what = this[what] + if (!SBData.isOnSkyblock) { + MC.sendCommand("join $what") + return@thenExecute + } + val joinName = getNameForFloor(what.replace(" ", "").lowercase()) + if (joinName == null) { + source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.unknown", what)) + } else { + source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.success", + joinName)) + MC.sendCommand("joininstance $joinName") + } + } + } + thenExecute { + source.sendFeedback(Text.translatable("firmament.quick-commands.join.explain")) + } + } + } + + fun CommandContext<DefaultSource>.getNameForFloor(w: String): String? { + val kuudraLevel = removePartialPrefix(w, "kuudratier") ?: removePartialPrefix(w, "tier") + if (kuudraLevel != null) { + val l = kuudraLevel.toIntOrNull()?.let { it - 1 } ?: kuudraLevelNames.indexOfFirst { + it.startsWith( + kuudraLevel, + true + ) + } + if (l !in kuudraLevelNames.indices) { + source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.unknown-kuudra", + kuudraLevel)) + return null + } + return "KUUDRA_${kuudraLevelNames[l]}" + } + val masterLevel = removePartialPrefix(w, "master") + val normalLevel = + removePartialPrefix(w, "floor") ?: removePartialPrefix(w, "catacombs") ?: removePartialPrefix(w, "dungeons") + val dungeonLevel = masterLevel ?: normalLevel + if (dungeonLevel != null) { + val l = dungeonLevel.toIntOrNull()?.let { it - 1 } ?: dungeonLevelNames.indexOfFirst { + it.startsWith( + dungeonLevel, + true + ) + } + if (masterLevel == null && (l == -1 || null != removePartialPrefix(w, "entrance"))) { + return "CATACOMBS_ENTRANCE" + } + if (l !in dungeonLevelNames.indices) { + source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.unknown-catacombs", + kuudraLevel)) + return null + } + return "${if (masterLevel != null) "MASTER_" else ""}CATACOMBS_FLOOR_${dungeonLevelNames[l]}" + } + return null + } +} diff --git a/src/main/kotlin/features/debug/DebugLogger.kt b/src/main/kotlin/features/debug/DebugLogger.kt new file mode 100644 index 0000000..ab06030 --- /dev/null +++ b/src/main/kotlin/features/debug/DebugLogger.kt @@ -0,0 +1,13 @@ + +package moe.nea.firmament.features.debug + +import net.minecraft.text.Text +import moe.nea.firmament.util.MC + +class DebugLogger(val tag: String) { + fun isEnabled() = DeveloperFeatures.isEnabled // TODO: allow filtering by tag + fun log(text: () -> String) { + if (!isEnabled()) return + MC.sendChat(Text.literal(text())) + } +} diff --git a/src/main/kotlin/features/debug/DebugView.kt b/src/main/kotlin/features/debug/DebugView.kt new file mode 100644 index 0000000..7e1b8ec --- /dev/null +++ b/src/main/kotlin/features/debug/DebugView.kt @@ -0,0 +1,38 @@ + + +package moe.nea.firmament.features.debug + +import moe.nea.firmament.Firmament +import moe.nea.firmament.events.TickEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.util.TimeMark + +object DebugView : FirmamentFeature { + private data class StoredVariable<T>( + val obj: T, + val timer: TimeMark, + ) + + private val storedVariables: MutableMap<String, StoredVariable<*>> = sortedMapOf() + override val identifier: String + get() = "debug-view" + override val defaultEnabled: Boolean + get() = Firmament.DEBUG + + fun <T : Any?> showVariable(label: String, obj: T) { + synchronized(this) { + storedVariables[label] = StoredVariable(obj, TimeMark.now()) + } + } + + fun recalculateDebugWidget() { + } + + override fun onLoad() { + TickEvent.subscribe { + synchronized(this) { + recalculateDebugWidget() + } + } + } +} diff --git a/src/main/kotlin/features/debug/DeveloperFeatures.kt b/src/main/kotlin/features/debug/DeveloperFeatures.kt new file mode 100644 index 0000000..20c0cfd --- /dev/null +++ b/src/main/kotlin/features/debug/DeveloperFeatures.kt @@ -0,0 +1,55 @@ + + +package moe.nea.firmament.features.debug + +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import kotlin.io.path.absolute +import kotlin.io.path.exists +import net.minecraft.client.MinecraftClient +import net.minecraft.text.Text +import moe.nea.firmament.Firmament +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.TimeMark +import moe.nea.firmament.util.errorBoundary +import moe.nea.firmament.util.iterate + +object DeveloperFeatures : FirmamentFeature { + override val identifier: String + get() = "developer" + override val config: TConfig + get() = TConfig + override val defaultEnabled: Boolean + get() = Firmament.DEBUG + + val gradleDir = + Path.of(".").absolute() + .iterate { it.parent } + .find { it.resolve("settings.gradle.kts").exists() } + + object TConfig : ManagedConfig("developer") { + val autoRebuildResources by toggle("auto-rebuild") { false } + } + + @JvmStatic + fun hookOnBeforeResourceReload(client: MinecraftClient): CompletableFuture<Void> { + val reloadFuture = if (TConfig.autoRebuildResources && isEnabled && gradleDir != null) { + val builder = ProcessBuilder("./gradlew", ":processResources") + builder.directory(gradleDir.toFile()) + builder.inheritIO() + val process = builder.start() + MC.player?.sendMessage(Text.translatable("firmament.dev.resourcerebuild.start")) + val startTime = TimeMark.now() + process.toHandle().onExit().thenApply { + MC.player?.sendMessage(Text.stringifiedTranslatable("firmament.dev.resourcerebuild.done", startTime.passedTime())) + Unit + } + } else { + CompletableFuture.completedFuture(Unit) + } + return reloadFuture.thenCompose { client.reloadResources() } + } +} + diff --git a/src/main/kotlin/features/debug/MinorTrolling.kt b/src/main/kotlin/features/debug/MinorTrolling.kt new file mode 100644 index 0000000..32035a6 --- /dev/null +++ b/src/main/kotlin/features/debug/MinorTrolling.kt @@ -0,0 +1,27 @@ + + +package moe.nea.firmament.features.debug + +import net.minecraft.text.Text +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ModifyChatEvent +import moe.nea.firmament.features.FirmamentFeature + + +// In memorian Dulkir +object MinorTrolling : FirmamentFeature { + override val identifier: String + get() = "minor-trolling" + + val trollers = listOf("nea89o", "lrg89") + val t = "From(?: \\[[^\\]]+])? ([^:]+): (.*)".toRegex() + + @Subscribe + fun onTroll(it: ModifyChatEvent) { + val m = t.matchEntire(it.unformattedString) ?: return + val (_, name, text) = m.groupValues + if (name !in trollers) return + if (!text.startsWith("c:")) return + it.replaceWith = Text.literal(text.substring(2).replace("&", "§")) + } +} diff --git a/src/main/kotlin/features/debug/PowerUserTools.kt b/src/main/kotlin/features/debug/PowerUserTools.kt new file mode 100644 index 0000000..7893eff --- /dev/null +++ b/src/main/kotlin/features/debug/PowerUserTools.kt @@ -0,0 +1,193 @@ + + +package moe.nea.firmament.features.debug + +import net.minecraft.block.SkullBlock +import net.minecraft.block.entity.SkullBlockEntity +import net.minecraft.component.DataComponentTypes +import net.minecraft.entity.Entity +import net.minecraft.entity.LivingEntity +import net.minecraft.item.ItemStack +import net.minecraft.item.Items +import net.minecraft.text.Text +import net.minecraft.util.hit.BlockHitResult +import net.minecraft.util.hit.EntityHitResult +import net.minecraft.util.hit.HitResult +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.CommandEvent +import moe.nea.firmament.events.CustomItemModelEvent +import moe.nea.firmament.events.HandledScreenKeyPressedEvent +import moe.nea.firmament.events.ItemTooltipEvent +import moe.nea.firmament.events.ScreenChangeEvent +import moe.nea.firmament.events.TickEvent +import moe.nea.firmament.events.WorldKeyboardEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.features.texturepack.CustomSkyBlockTextures +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.mixins.accessor.AccessorHandledScreen +import moe.nea.firmament.util.ClipboardUtils +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.focusedItemStack +import moe.nea.firmament.util.skyBlockId + +object PowerUserTools : FirmamentFeature { + override val identifier: String + get() = "power-user" + + object TConfig : ManagedConfig(identifier) { + val showItemIds by toggle("show-item-id") { false } + val copyItemId by keyBindingWithDefaultUnbound("copy-item-id") + val copyTexturePackId by keyBindingWithDefaultUnbound("copy-texture-pack-id") + val copyNbtData by keyBindingWithDefaultUnbound("copy-nbt-data") + val copySkullTexture by keyBindingWithDefaultUnbound("copy-skull-texture") + val copyEntityData by keyBindingWithDefaultUnbound("entity-data") + } + + override val config + get() = TConfig + + var lastCopiedStack: Pair<ItemStack, Text>? = null + set(value) { + field = value + if (value != null) + lastCopiedStackViewTime = true + } + var lastCopiedStackViewTime = false + + override fun onLoad() { + TickEvent.subscribe { + if (!lastCopiedStackViewTime) + lastCopiedStack = null + lastCopiedStackViewTime = false + } + ScreenChangeEvent.subscribe { + lastCopiedStack = null + } + } + + fun debugFormat(itemStack: ItemStack): Text { + return Text.literal(itemStack.skyBlockId?.toString() ?: itemStack.toString()) + } + + @Subscribe + fun onEntityInfo(event: WorldKeyboardEvent) { + if (!event.matches(TConfig.copyEntityData)) return + val target = (MC.instance.crosshairTarget as? EntityHitResult)?.entity + if (target == null) { + MC.sendChat(Text.translatable("firmament.poweruser.entity.fail")) + return + } + showEntity(target) + } + + fun showEntity(target: Entity) { + MC.sendChat(Text.translatable("firmament.poweruser.entity.type", target.type)) + MC.sendChat(Text.translatable("firmament.poweruser.entity.name", target.name)) + MC.sendChat(Text.stringifiedTranslatable("firmament.poweruser.entity.position", target.pos)) + if (target is LivingEntity) { + MC.sendChat(Text.translatable("firmament.poweruser.entity.armor")) + for (armorItem in target.armorItems) { + MC.sendChat(Text.translatable("firmament.poweruser.entity.armor.item", debugFormat(armorItem))) + } + } + MC.sendChat(Text.stringifiedTranslatable("firmament.poweruser.entity.passengers", target.passengerList.size)) + target.passengerList.forEach { + showEntity(it) + } + } + + + @Subscribe + fun copyInventoryInfo(it: HandledScreenKeyPressedEvent) { + if (it.screen !is AccessorHandledScreen) return + val item = it.screen.focusedItemStack ?: return + if (it.matches(TConfig.copyItemId)) { + val sbId = item.skyBlockId + if (sbId == null) { + lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skyblockid.fail")) + return + } + ClipboardUtils.setTextContent(sbId.neuItem) + lastCopiedStack = + Pair(item, Text.stringifiedTranslatable("firmament.tooltip.copied.skyblockid", sbId.neuItem)) + } else if (it.matches(TConfig.copyTexturePackId)) { + val model = CustomItemModelEvent.getModelIdentifier(item) + if (model == null) { + lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.modelid.fail")) + return + } + ClipboardUtils.setTextContent(model.toString()) + lastCopiedStack = + Pair(item, Text.stringifiedTranslatable("firmament.tooltip.copied.modelid", model.toString())) + } else if (it.matches(TConfig.copyNbtData)) { + // TODO: copy full nbt + val nbt = item.get(DataComponentTypes.CUSTOM_DATA)?.nbt?.toString() ?: "<empty>" + ClipboardUtils.setTextContent(nbt) + lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.nbt")) + } else if (it.matches(TConfig.copySkullTexture)) { + if (item.item != Items.PLAYER_HEAD) { + lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skull-id.fail.no-skull")) + return + } + val profile = item.get(DataComponentTypes.PROFILE) + if (profile == null) { + lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skull-id.fail.no-profile")) + return + } + val skullTexture = CustomSkyBlockTextures.getSkullTexture(profile) + if (skullTexture == null) { + lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skull-id.fail.no-texture")) + return + } + ClipboardUtils.setTextContent(skullTexture.toString()) + lastCopiedStack = + Pair( + item, + Text.stringifiedTranslatable("firmament.tooltip.copied.skull-id", skullTexture.toString()) + ) + println("Copied skull id: $skullTexture") + } + } + + @Subscribe + fun onCopyWorldInfo(it: WorldKeyboardEvent) { + if (it.matches(TConfig.copySkullTexture)) { + val p = MC.camera ?: return + val blockHit = p.raycast(20.0, 0.0f, false) ?: return + if (blockHit.type != HitResult.Type.BLOCK || blockHit !is BlockHitResult) { + MC.sendChat(Text.translatable("firmament.tooltip.copied.skull.fail")) + return + } + val blockAt = p.world.getBlockState(blockHit.blockPos)?.block + val entity = p.world.getBlockEntity(blockHit.blockPos) + if (blockAt !is SkullBlock || entity !is SkullBlockEntity || entity.owner == null) { + MC.sendChat(Text.translatable("firmament.tooltip.copied.skull.fail")) + return + } + val id = CustomSkyBlockTextures.getSkullTexture(entity.owner!!) + if (id == null) { + MC.sendChat(Text.translatable("firmament.tooltip.copied.skull.fail")) + } else { + ClipboardUtils.setTextContent(id.toString()) + MC.sendChat(Text.stringifiedTranslatable("firmament.tooltip.copied.skull", id.toString())) + } + } + } + + @Subscribe + fun addItemId(it: ItemTooltipEvent) { + if (TConfig.showItemIds) { + val id = it.stack.skyBlockId ?: return + it.lines.add(Text.stringifiedTranslatable("firmament.tooltip.skyblockid", id.neuItem)) + } + val (item, text) = lastCopiedStack ?: return + if (!ItemStack.areEqual(item, it.stack)) { + lastCopiedStack = null + return + } + lastCopiedStackViewTime = true + it.lines.add(text) + } + + +} diff --git a/src/main/kotlin/features/diana/AncestralSpadeSolver.kt b/src/main/kotlin/features/diana/AncestralSpadeSolver.kt new file mode 100644 index 0000000..39ca6d3 --- /dev/null +++ b/src/main/kotlin/features/diana/AncestralSpadeSolver.kt @@ -0,0 +1,131 @@ + +package moe.nea.firmament.features.diana + +import kotlin.time.Duration.Companion.seconds +import net.minecraft.particle.ParticleTypes +import net.minecraft.sound.SoundEvents +import net.minecraft.util.math.Vec3d +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ParticleSpawnEvent +import moe.nea.firmament.events.SoundReceiveEvent +import moe.nea.firmament.events.WorldKeyboardEvent +import moe.nea.firmament.events.WorldReadyEvent +import moe.nea.firmament.events.WorldRenderLastEvent +import moe.nea.firmament.events.subscription.SubscriptionOwner +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SBData +import moe.nea.firmament.util.SkyBlockIsland +import moe.nea.firmament.util.SkyblockId +import moe.nea.firmament.util.TimeMark +import moe.nea.firmament.util.WarpUtil +import moe.nea.firmament.util.render.RenderInWorldContext +import moe.nea.firmament.util.skyBlockId + +object AncestralSpadeSolver : SubscriptionOwner { + var lastDing = TimeMark.farPast() + private set + private val pitches = mutableListOf<Float>() + val particlePositions = mutableListOf<Vec3d>() + var nextGuess: Vec3d? = null + private set + + val ancestralSpadeId = SkyblockId("ANCESTRAL_SPADE") + private var lastTeleportAttempt = TimeMark.farPast() + + fun isEnabled() = + DianaWaypoints.TConfig.ancestralSpadeSolver + && SBData.skyblockLocation == SkyBlockIsland.HUB + && MC.player?.inventory?.containsAny { it.skyBlockId == ancestralSpadeId } == true // TODO: add a reactive property here + + @Subscribe + fun onKeyBind(event: WorldKeyboardEvent) { + if (!isEnabled()) return + if (!event.matches(DianaWaypoints.TConfig.ancestralSpadeTeleport)) return + + if (lastTeleportAttempt.passedTime() < 3.seconds) return + WarpUtil.teleportToNearestWarp(SkyBlockIsland.HUB, nextGuess ?: return) + lastTeleportAttempt = TimeMark.now() + } + + @Subscribe + fun onParticleSpawn(event: ParticleSpawnEvent) { + if (!isEnabled()) return + if (event.particleEffect != ParticleTypes.DRIPPING_LAVA) return + if (event.offset.x != 0.0F || event.offset.y != 0F || event.offset.z != 0F) + return + particlePositions.add(event.position) + if (particlePositions.size > 20) { + particlePositions.removeFirst() + } + } + + @Subscribe + fun onPlaySound(event: SoundReceiveEvent) { + if (!isEnabled()) return + if (!SoundEvents.BLOCK_NOTE_BLOCK_HARP.matchesId(event.sound.value().id)) return + + if (lastDing.passedTime() > 1.seconds) { + particlePositions.clear() + pitches.clear() + } + lastDing = TimeMark.now() + + pitches.add(event.pitch) + if (pitches.size > 20) { + pitches.removeFirst() + } + + if (particlePositions.size < 3) { + return + } + + val averagePitchDelta = + if (pitches.isEmpty()) return + else pitches + .zipWithNext { a, b -> b - a } + .average() + + val soundDistanceEstimate = (Math.E / averagePitchDelta) - particlePositions.first().distanceTo(event.position) + + if (soundDistanceEstimate > 1000) { + return + } + + val lastParticleDirection = particlePositions + .takeLast(3) + .let { (a, _, b) -> b.subtract(a) } + .normalize() + + nextGuess = event.position.add(lastParticleDirection.multiply(soundDistanceEstimate)) + } + + @Subscribe + fun onWorldRender(event: WorldRenderLastEvent) { + if (!isEnabled()) return + RenderInWorldContext.renderInWorld(event) { + nextGuess?.let { + color(1f, 1f, 0f, 0.5f) + tinyBlock(it, 1f) + color(1f, 1f, 0f, 1f) + tracer(it, lineWidth = 3f) + } + if (particlePositions.size > 2 && lastDing.passedTime() < 10.seconds && nextGuess != null) { + color(0f, 1f, 0f, 0.7f) + line(particlePositions) + } + } + } + + @Subscribe + fun onSwapWorld(event: WorldReadyEvent) { + nextGuess = null + particlePositions.clear() + pitches.clear() + lastDing = TimeMark.farPast() + } + + override val delegateFeature: FirmamentFeature + get() = DianaWaypoints + +} diff --git a/src/main/kotlin/features/diana/DianaWaypoints.kt b/src/main/kotlin/features/diana/DianaWaypoints.kt new file mode 100644 index 0000000..0a34eaa --- /dev/null +++ b/src/main/kotlin/features/diana/DianaWaypoints.kt @@ -0,0 +1,35 @@ + +package moe.nea.firmament.features.diana + +import moe.nea.firmament.events.AttackBlockEvent +import moe.nea.firmament.events.ParticleSpawnEvent +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.events.SoundReceiveEvent +import moe.nea.firmament.events.UseBlockEvent +import moe.nea.firmament.events.WorldKeyboardEvent +import moe.nea.firmament.events.WorldReadyEvent +import moe.nea.firmament.events.WorldRenderLastEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig + +object DianaWaypoints : FirmamentFeature { + override val identifier get() = "diana" + override val config get() = TConfig + + object TConfig : ManagedConfig(identifier) { + val ancestralSpadeSolver by toggle("ancestral-spade") { true } + val ancestralSpadeTeleport by keyBindingWithDefaultUnbound("ancestral-teleport") + val nearbyWaypoints by toggle("nearby-waypoints") { true } + } + + override fun onLoad() { + UseBlockEvent.subscribe { + NearbyBurrowsSolver.onBlockClick(it.hitResult.blockPos) + } + AttackBlockEvent.subscribe { + NearbyBurrowsSolver.onBlockClick(it.blockPos) + } + } +} + + diff --git a/src/main/kotlin/features/diana/NearbyBurrowsSolver.kt b/src/main/kotlin/features/diana/NearbyBurrowsSolver.kt new file mode 100644 index 0000000..7158bb9 --- /dev/null +++ b/src/main/kotlin/features/diana/NearbyBurrowsSolver.kt @@ -0,0 +1,144 @@ + +package moe.nea.firmament.features.diana + +import kotlin.time.Duration.Companion.seconds +import net.minecraft.particle.ParticleTypes +import net.minecraft.util.math.BlockPos +import net.minecraft.util.math.MathHelper +import net.minecraft.util.math.Position +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ParticleSpawnEvent +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.events.WorldReadyEvent +import moe.nea.firmament.events.WorldRenderLastEvent +import moe.nea.firmament.events.subscription.SubscriptionOwner +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.util.TimeMark +import moe.nea.firmament.util.mutableMapWithMaxSize +import moe.nea.firmament.util.render.RenderInWorldContext.Companion.renderInWorld + +object NearbyBurrowsSolver : SubscriptionOwner { + + + private val recentlyDugBurrows: MutableMap<BlockPos, TimeMark> = mutableMapWithMaxSize(20) + private val recentEnchantParticles: MutableMap<BlockPos, TimeMark> = mutableMapWithMaxSize(500) + private var lastBlockClick: BlockPos? = null + + enum class BurrowType { + START, MOB, TREASURE + } + + val burrows = mutableMapOf<BlockPos, BurrowType>() + + @Subscribe + fun onChatEvent(event: ProcessChatEvent) { + val lastClickedBurrow = lastBlockClick ?: return + if (event.unformattedString.startsWith("You dug out a Griffin Burrow!") || + event.unformattedString.startsWith(" ☠ You were killed by") || + event.unformattedString.startsWith("You finished the Griffin burrow chain!") + ) { + markAsDug(lastClickedBurrow) + burrows.remove(lastClickedBurrow) + } + } + + + fun wasRecentlyDug(blockPos: BlockPos): Boolean { + val lastDigTime = recentlyDugBurrows[blockPos] ?: TimeMark.farPast() + return lastDigTime.passedTime() < 10.seconds + } + + fun markAsDug(blockPos: BlockPos) { + recentlyDugBurrows[blockPos] = TimeMark.now() + } + + fun wasRecentlyEnchanted(blockPos: BlockPos): Boolean { + val lastEnchantTime = recentEnchantParticles[blockPos] ?: TimeMark.farPast() + return lastEnchantTime.passedTime() < 4.seconds + } + + fun markAsEnchanted(blockPos: BlockPos) { + recentEnchantParticles[blockPos] = TimeMark.now() + } + + @Subscribe + fun onParticles(event: ParticleSpawnEvent) { + if (!DianaWaypoints.TConfig.nearbyWaypoints) return + + val position: BlockPos = event.position.toBlockPos().down() + + if (wasRecentlyDug(position)) return + + val isEven50Spread = (event.offset.x == 0.5f && event.offset.z == 0.5f) + + if (event.particleEffect.type == ParticleTypes.ENCHANT) { + if (event.count == 5 && event.speed == 0.05F && event.offset.y == 0.4F && isEven50Spread) { + markAsEnchanted(position) + } + return + } + + if (!wasRecentlyEnchanted(position)) return + + if (event.particleEffect.type == ParticleTypes.ENCHANTED_HIT + && event.count == 4 + && event.speed == 0.01F + && event.offset.y == 0.1f + && isEven50Spread + ) { + burrows[position] = BurrowType.START + } + if (event.particleEffect.type == ParticleTypes.CRIT + && event.count == 3 + && event.speed == 0.01F + && event.offset.y == 0.1F + && isEven50Spread + ) { + burrows[position] = BurrowType.MOB + } + if (event.particleEffect.type == ParticleTypes.DRIPPING_LAVA + && event.count == 2 + && event.speed == 0.01F + && event.offset.y == 0.1F + && event.offset.x == 0.35F && event.offset.z == 0.35f + ) { + burrows[position] = BurrowType.TREASURE + } + } + + @Subscribe + fun onRender(event: WorldRenderLastEvent) { + if (!DianaWaypoints.TConfig.nearbyWaypoints) return + renderInWorld(event) { + for ((location, burrow) in burrows) { + when (burrow) { + BurrowType.START -> color(.2f, .8f, .2f, 0.4f) + BurrowType.MOB -> color(0.3f, 0.4f, 0.9f, 0.4f) + BurrowType.TREASURE -> color(1f, 0.7f, 0.2f, 0.4f) + } + block(location) + } + } + } + + @Subscribe + fun onSwapWorld(worldReadyEvent: WorldReadyEvent) { + burrows.clear() + recentEnchantParticles.clear() + recentlyDugBurrows.clear() + lastBlockClick = null + } + + fun onBlockClick(blockPos: BlockPos) { + if (!DianaWaypoints.TConfig.nearbyWaypoints) return + burrows.remove(blockPos) + lastBlockClick = blockPos + } + + override val delegateFeature: FirmamentFeature + get() = DianaWaypoints +} + +fun Position.toBlockPos(): BlockPos { + return BlockPos(MathHelper.floor(x), MathHelper.floor(y), MathHelper.floor(z)) +} diff --git a/src/main/kotlin/features/events/anniversity/AnniversaryFeatures.kt b/src/main/kotlin/features/events/anniversity/AnniversaryFeatures.kt new file mode 100644 index 0000000..8926a95 --- /dev/null +++ b/src/main/kotlin/features/events/anniversity/AnniversaryFeatures.kt @@ -0,0 +1,224 @@ + +package moe.nea.firmament.features.events.anniversity + +import io.github.notenoughupdates.moulconfig.observer.ObservableList +import io.github.notenoughupdates.moulconfig.xml.Bind +import moe.nea.jarvis.api.Point +import kotlin.time.Duration.Companion.seconds +import net.minecraft.entity.passive.PigEntity +import net.minecraft.util.math.BlockPos +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.EntityInteractionEvent +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.events.TickEvent +import moe.nea.firmament.events.WorldReadyEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.gui.hud.MoulConfigHud +import moe.nea.firmament.rei.SBItemEntryDefinition +import moe.nea.firmament.repo.ItemNameLookup +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SHORT_NUMBER_FORMAT +import moe.nea.firmament.util.SkyblockId +import moe.nea.firmament.util.TimeMark +import moe.nea.firmament.util.parseShortNumber +import moe.nea.firmament.util.useMatch + +object AnniversaryFeatures : FirmamentFeature { + override val identifier: String + get() = "anniversary" + + object TConfig : ManagedConfig(identifier) { + val enableShinyPigTracker by toggle("shiny-pigs") {true} + val trackPigCooldown by position("pig-hud", 200, 300) { Point(0.1, 0.2) } + } + + override val config: ManagedConfig? + get() = TConfig + + data class ClickedPig( + val clickedAt: TimeMark, + val startLocation: BlockPos, + val pigEntity: PigEntity + ) { + @Bind("timeLeft") + fun getTimeLeft(): Double = 1 - clickedAt.passedTime() / pigDuration + } + + val clickedPigs = ObservableList<ClickedPig>(mutableListOf()) + var lastClickedPig: PigEntity? = null + + val pigDuration = 90.seconds + + @Subscribe + fun onTick(event: TickEvent) { + clickedPigs.removeIf { it.clickedAt.passedTime() > pigDuration } + } + + val pattern = "SHINY! You extracted (?<reward>.*) from the piglet's orb!".toPattern() + + @Subscribe + fun onChat(event: ProcessChatEvent) { + if(!TConfig.enableShinyPigTracker)return + if (event.unformattedString == "Oink! Bring the pig back to the Shiny Orb!") { + val pig = lastClickedPig ?: return + // TODO: store proper location based on the orb location, maybe + val startLocation = pig.blockPos ?: return + clickedPigs.add(ClickedPig(TimeMark.now(), startLocation, pig)) + lastClickedPig = null + } + if (event.unformattedString == "SHINY! The orb is charged! Click on it for loot!") { + val player = MC.player ?: return + val lowest = + clickedPigs.minByOrNull { it.startLocation.getSquaredDistance(player.pos) } ?: return + clickedPigs.remove(lowest) + } + pattern.useMatch(event.unformattedString) { + val reward = group("reward") + val parsedReward = parseReward(reward) + addReward(parsedReward) + PigCooldown.rewards.atOnce { + PigCooldown.rewards.clear() + rewards.mapTo(PigCooldown.rewards) { PigCooldown.DisplayReward(it) } + } + } + } + + fun addReward(reward: Reward) { + val it = rewards.listIterator() + while (it.hasNext()) { + val merged = reward.mergeWith(it.next()) ?: continue + it.set(merged) + return + } + rewards.add(reward) + } + + val rewards = mutableListOf<Reward>() + + fun <T> ObservableList<T>.atOnce(block: () -> Unit) { + val oldObserver = observer + observer = null + block() + observer = oldObserver + update() + } + + sealed interface Reward { + fun mergeWith(other: Reward): Reward? + data class EXP(val amount: Double, val skill: String) : Reward { + override fun mergeWith(other: Reward): Reward? { + if (other is EXP && other.skill == skill) + return EXP(amount + other.amount, skill) + return null + } + } + + data class Coins(val amount: Double) : Reward { + override fun mergeWith(other: Reward): Reward? { + if (other is Coins) + return Coins(other.amount + amount) + return null + } + } + + data class Items(val amount: Int, val item: SkyblockId) : Reward { + override fun mergeWith(other: Reward): Reward? { + if (other is Items && other.item == item) + return Items(amount + other.amount, item) + return null + } + } + + data class Unknown(val text: String) : Reward { + override fun mergeWith(other: Reward): Reward? { + return null + } + } + } + + val expReward = "\\+(?<exp>$SHORT_NUMBER_FORMAT) (?<kind>[^ ]+) XP".toPattern() + val coinReward = "\\+(?<amount>$SHORT_NUMBER_FORMAT) coins".toPattern() + val itemReward = "(?:(?<amount>[0-9]+)x )?(?<name>.*)".toPattern() + fun parseReward(string: String): Reward { + expReward.useMatch<Unit>(string) { + val exp = parseShortNumber(group("exp")) + val kind = group("kind") + return Reward.EXP(exp, kind) + } + coinReward.useMatch<Unit>(string) { + val coins = parseShortNumber(group("amount")) + return Reward.Coins(coins) + } + itemReward.useMatch(string) { + val amount = group("amount")?.toIntOrNull() ?: 1 + val name = group("name") + val item = ItemNameLookup.guessItemByName(name, false) ?: return@useMatch + return Reward.Items(amount, item) + } + return Reward.Unknown(string) + } + + @Subscribe + fun onWorldClear(event: WorldReadyEvent) { + lastClickedPig = null + clickedPigs.clear() + } + + @Subscribe + fun onEntityClick(event: EntityInteractionEvent) { + if (event.entity is PigEntity) { + lastClickedPig = event.entity + } + } + + @Subscribe + fun init(event: WorldReadyEvent) { + PigCooldown.forceInit() + } + + object PigCooldown : MoulConfigHud("anniversary_pig", TConfig.trackPigCooldown) { + override fun shouldRender(): Boolean { + return clickedPigs.isNotEmpty() && TConfig.enableShinyPigTracker + } + + @Bind("pigs") + fun getPigs() = clickedPigs + + class DisplayReward(val backedBy: Reward) { + @Bind + fun count(): String { + return when (backedBy) { + is Reward.Coins -> backedBy.amount + is Reward.EXP -> backedBy.amount + is Reward.Items -> backedBy.amount + is Reward.Unknown -> 0 + }.toString() + } + + val itemStack = if (backedBy is Reward.Items) { + SBItemEntryDefinition.getEntry(backedBy.item, backedBy.amount) + } else { + SBItemEntryDefinition.getEntry(SkyblockId.NULL) + } + + @Bind + fun name(): String { + return when (backedBy) { + is Reward.Coins -> "Coins" + is Reward.EXP -> backedBy.skill + is Reward.Items -> itemStack.value.asItemStack().name.string + is Reward.Unknown -> backedBy.text + } + } + + @Bind + fun isKnown() = backedBy !is Reward.Unknown + } + + @get:Bind("rewards") + val rewards = ObservableList<DisplayReward>(mutableListOf()) + + } + +} diff --git a/src/main/kotlin/features/events/carnival/CarnivalFeatures.kt b/src/main/kotlin/features/events/carnival/CarnivalFeatures.kt new file mode 100644 index 0000000..1e6d97a --- /dev/null +++ b/src/main/kotlin/features/events/carnival/CarnivalFeatures.kt @@ -0,0 +1,17 @@ + +package moe.nea.firmament.features.events.carnival + +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig + +object CarnivalFeatures : FirmamentFeature { + object TConfig : ManagedConfig(identifier) { + val enableBombSolver by toggle("bombs-solver") { true } + val displayTutorials by toggle("tutorials") { true } + } + + override val config: ManagedConfig? + get() = TConfig + override val identifier: String + get() = "carnival" +} diff --git a/src/main/kotlin/features/events/carnival/MinesweeperHelper.kt b/src/main/kotlin/features/events/carnival/MinesweeperHelper.kt new file mode 100644 index 0000000..06caf86 --- /dev/null +++ b/src/main/kotlin/features/events/carnival/MinesweeperHelper.kt @@ -0,0 +1,276 @@ + +package moe.nea.firmament.features.events.carnival + +import io.github.notenoughupdates.moulconfig.observer.ObservableList +import io.github.notenoughupdates.moulconfig.platform.ModernItemStack +import io.github.notenoughupdates.moulconfig.xml.Bind +import java.util.UUID +import net.minecraft.block.Blocks +import net.minecraft.item.Item +import net.minecraft.item.ItemStack +import net.minecraft.item.Items +import net.minecraft.text.ClickEvent +import net.minecraft.text.Text +import net.minecraft.util.math.BlockPos +import net.minecraft.world.WorldAccess +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.commands.thenExecute +import moe.nea.firmament.events.AttackBlockEvent +import moe.nea.firmament.events.CommandEvent +import moe.nea.firmament.events.EntityUpdateEvent +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.events.WorldReadyEvent +import moe.nea.firmament.events.WorldRenderLastEvent +import moe.nea.firmament.features.debug.DebugLogger +import moe.nea.firmament.util.LegacyFormattingCode +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.MoulConfigUtils +import moe.nea.firmament.util.ScreenUtil +import moe.nea.firmament.util.SkyblockId +import moe.nea.firmament.util.item.createSkullItem +import moe.nea.firmament.util.render.RenderInWorldContext +import moe.nea.firmament.util.setSkyBlockFirmamentUiId +import moe.nea.firmament.util.skyBlockId +import moe.nea.firmament.util.useMatch + +object MinesweeperHelper { + val sandBoxLow = BlockPos(-112, 72, -11) + val sandBoxHigh = BlockPos(-106, 72, -5) + val boardSize = Pair(sandBoxHigh.x - sandBoxLow.x, sandBoxHigh.z - sandBoxLow.z) + + val gameStartMessage = "[NPC] Carnival Pirateman: Good luck, matey!" + val gameEndMessage = "Fruit Digging" + val bombPattern = "MINES! There (are|is) (?<bombCount>[0-8]) bombs? hidden nearby\\.".toPattern() + val startGameQuestion = "[NPC] Carnival Pirateman: Would ye like to do some Fruit Digging?" + + + enum class Piece( + @get:Bind("fruitName") + val fruitName: String, + val points: Int, + val specialAbility: String, + val totalPerBoard: Int, + val textureHash: String, + val fruitColor: LegacyFormattingCode, + ) { + COCONUT("Coconut", + 200, + "Prevents a bomb from exploding next turn", + 3, + "10ceb1455b471d016a9f06d25f6e468df9fcf223e2c1e4795b16e84fcca264ee", + LegacyFormattingCode.DARK_PURPLE), + APPLE("Apple", + 100, + "Gains 100 points for each apple dug up", + 8, + "17ea278d6225c447c5943d652798d0bbbd1418434ce8c54c54fdac79994ddd6c", + LegacyFormattingCode.GREEN), + WATERMELON("Watermelon", + 100, + "Blows up an adjacent fruit for half the points", + 4, + "efe4ef83baf105e8dee6cf03dfe7407f1911b3b9952c891ae34139560f2931d6", + LegacyFormattingCode.DARK_BLUE), + DURIAN("Durian", + 800, + "Halves the points earned in the next turn", + 2, + "ac268d36c2c6047ffeec00124096376b56dbb4d756a55329363a1b27fcd659cd", + LegacyFormattingCode.DARK_PURPLE), + MANGO("Mango", + 300, + "Just an ordinary fruit", + 10, + "f363a62126a35537f8189343a22660de75e810c6ac004a7d3da65f1c040a839", + LegacyFormattingCode.GREEN), + DRAGON_FRUIT("Dragonfruit", + 1200, + "Halves the points earned in the next turn", + 1, + "3cc761bcb0579763d9b8ab6b7b96fa77eb6d9605a804d838fec39e7b25f95591", + LegacyFormattingCode.LIGHT_PURPLE), + POMEGRANATE("Pomegranate", + 200, + "Grants an extra 50% more points in the next turn", + 4, + "40824d18079042d5769f264f44394b95b9b99ce689688cc10c9eec3f882ccc08", + LegacyFormattingCode.DARK_BLUE), + CHERRY("Cherry", + 200, + "The second cherry grants 300 bonus points", + 2, + "c92b099a62cd2fbf8ada09dec145c75d7fda4dc57b968bea3a8fa11e37aa48b2", + LegacyFormattingCode.DARK_PURPLE), + BOMB("Bomb", + -1, + "Destroys nearby fruit", + 15, + "a76a2811d1e176a07b6d0a657b910f134896ce30850f6e80c7c83732d85381ea", + LegacyFormattingCode.DARK_RED), + RUM("Rum", + -1, + "Stops your dowsing ability for one turn", + 5, + "407b275d28b927b1bf7f6dd9f45fbdad2af8571c54c8f027d1bff6956fbf3c16", + LegacyFormattingCode.YELLOW), + ; + + val textureUrl = "http://textures.minecraft.net/texture/$textureHash" + val itemStack = createSkullItem(UUID.randomUUID(), textureUrl) + .setSkyBlockFirmamentUiId("MINESWEEPER_$name") + + @Bind + fun getIcon() = ModernItemStack.of(itemStack) + + @Bind + fun pieceLabel() = fruitColor.formattingCode + fruitName + + @Bind + fun boardLabel() = "§a$totalPerBoard§7/§rboard" + + @Bind("description") + fun getDescription() = buildString { + append(specialAbility) + if (points >= 0) { + append(" Default points: $points.") + } + } + } + + object TutorialScreen { + @get:Bind("pieces") + val pieces = ObservableList(Piece.entries.toList().reversed()) + + @get:Bind("modes") + val modes = ObservableList(DowsingMode.entries.toList()) + } + + enum class DowsingMode( + val itemType: Item, + @get:Bind("feature") + val feature: String, + @get:Bind("description") + val description: String, + ) { + MINES(Items.IRON_SHOVEL, "Bomb detection", "Tells you how many bombs are near the block"), + ANCHOR(Items.DIAMOND_SHOVEL, "Lowest fruit", "Shows you which block nearby contains the lowest scoring fruit"), + TREASURE(Items.GOLDEN_SHOVEL, "Highest fruit", "Tells you which kind of fruit is the highest scoring nearby"), + ; + + @Bind("itemType") + fun getItemStack() = ModernItemStack.of(ItemStack(itemType)) + + companion object { + val id = SkyblockId("CARNIVAL_SHOVEL") + fun fromItem(itemStack: ItemStack): DowsingMode? { + if (itemStack.skyBlockId != id) return null + return DowsingMode.entries.find { it.itemType == itemStack.item } + } + } + } + + data class BoardPosition( + val x: Int, + val y: Int + ) { + fun toBlockPos() = BlockPos(sandBoxLow.x + x, sandBoxLow.y, sandBoxLow.z + y) + + fun getBlock(world: WorldAccess) = world.getBlockState(toBlockPos()).block + fun isUnopened(world: WorldAccess) = getBlock(world) == Blocks.SAND + fun isOpened(world: WorldAccess) = getBlock(world) == Blocks.SANDSTONE + fun isScorched(world: WorldAccess) = getBlock(world) == Blocks.SANDSTONE_STAIRS + + companion object { + fun fromBlockPos(blockPos: BlockPos): BoardPosition? { + if (blockPos.y != sandBoxLow.y) return null + val x = blockPos.x - sandBoxLow.x + val y = blockPos.z - sandBoxLow.z + if (x < 0 || x >= boardSize.first) return null + if (y < 0 || y >= boardSize.second) return null + return BoardPosition(x, y) + } + } + } + + data class GameState( + val nearbyBombs: MutableMap<BoardPosition, Int> = mutableMapOf(), + val knownBombPositions: MutableSet<BoardPosition> = mutableSetOf(), + var lastClickedPosition: BoardPosition? = null, + var lastDowsingMode: DowsingMode? = null, + ) + + var gameState: GameState? = null + val log = DebugLogger("minesweeper") + + @Subscribe + fun onCommand(event: CommandEvent.SubCommand) { + event.subcommand("minesweepertutorial") { + thenExecute { + ScreenUtil.setScreenLater(MoulConfigUtils.loadScreen("carnival/minesweeper_tutorial", + TutorialScreen, + null)) + } + } + } + + @Subscribe + fun onWorldChange(event: WorldReadyEvent) { + gameState = null + } + + @Subscribe + fun onChat(event: ProcessChatEvent) { + if (CarnivalFeatures.TConfig.displayTutorials && event.unformattedString == startGameQuestion) { + MC.sendChat(Text.translatable("firmament.carnival.tutorial.minesweeper").styled { + it.withClickEvent(ClickEvent(ClickEvent.Action.RUN_COMMAND, "/firm minesweepertutorial")) + }) + } + if (!CarnivalFeatures.TConfig.enableBombSolver) { + gameState = null // TODO: replace this which a watchable property + return + } + if (event.unformattedString == gameStartMessage) { + gameState = GameState() + log.log { "Game started" } + } + if (event.unformattedString.trim() == gameEndMessage) { + gameState = null // TODO: add a loot tracker maybe? probably not, i dont think people care + log.log { "Finished game" } + } + val gs = gameState ?: return + bombPattern.useMatch(event.unformattedString) { + val bombCount = group("bombCount").toInt() + log.log { "Marking ${gs.lastClickedPosition} as having $bombCount nearby" } + val pos = gs.lastClickedPosition ?: return + gs.nearbyBombs[pos] = bombCount + } + } + + @Subscribe + fun onMobChange(event: EntityUpdateEvent) { + val gs = gameState ?: return + if (event !is EntityUpdateEvent.TrackedDataUpdate) return + // TODO: listen to state + } + + @Subscribe + fun onBlockClick(event: AttackBlockEvent) { + val gs = gameState ?: return + val boardPosition = BoardPosition.fromBlockPos(event.blockPos) + log.log { "Breaking block at ${event.blockPos} ($boardPosition)" } + gs.lastClickedPosition = boardPosition + gs.lastDowsingMode = DowsingMode.fromItem(event.player.inventory.mainHandStack) + } + + @Subscribe + fun onRender(event: WorldRenderLastEvent) { + val gs = gameState ?: return + RenderInWorldContext.renderInWorld(event) { + for ((pos, bombCount) in gs.nearbyBombs) { + this.text(pos.toBlockPos().up().toCenterPos(), Text.literal("§a$bombCount \uD83D\uDCA3")) + } + } + } + + +} diff --git a/src/main/kotlin/features/fixes/CompatibliltyFeatures.kt b/src/main/kotlin/features/fixes/CompatibliltyFeatures.kt new file mode 100644 index 0000000..7c43cf6 --- /dev/null +++ b/src/main/kotlin/features/fixes/CompatibliltyFeatures.kt @@ -0,0 +1,51 @@ + + +package moe.nea.firmament.features.fixes + +import net.fabricmc.loader.api.FabricLoader +import net.superkat.explosiveenhancement.api.ExplosiveApi +import net.minecraft.particle.ParticleTypes +import net.minecraft.util.math.Vec3d +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ParticleSpawnEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC + +object CompatibliltyFeatures : FirmamentFeature { + override val identifier: String + get() = "compatibility" + + object TConfig : ManagedConfig(identifier) { + val enhancedExplosions by toggle("explosion-enabled") { false } + val explosionSize by integer("explosion-power", 10, 50) { 1 } + } + + override val config: ManagedConfig? + get() = TConfig + + interface ExplosiveApiWrapper { + fun spawnParticle(vec3d: Vec3d, power: Float) + } + + class ExplosiveApiWrapperImpl : ExplosiveApiWrapper { + override fun spawnParticle(vec3d: Vec3d, power: Float) { + ExplosiveApi.spawnParticles(MC.world, vec3d.x, vec3d.y, vec3d.z, TConfig.explosionSize / 10F) + } + } + + val explosiveApiWrapper = if (FabricLoader.getInstance().isModLoaded("explosiveenhancement")) { + ExplosiveApiWrapperImpl() + } else null + + @Subscribe + fun onExplosion(it: ParticleSpawnEvent) { + if (TConfig.enhancedExplosions && + it.particleEffect.type == ParticleTypes.EXPLOSION_EMITTER && + explosiveApiWrapper != null + ) { + it.cancel() + explosiveApiWrapper.spawnParticle(it.position, 2F) + } + } +} diff --git a/src/main/kotlin/features/fixes/Fixes.kt b/src/main/kotlin/features/fixes/Fixes.kt new file mode 100644 index 0000000..d7b7a1c --- /dev/null +++ b/src/main/kotlin/features/fixes/Fixes.kt @@ -0,0 +1,71 @@ + + +package moe.nea.firmament.features.fixes + +import moe.nea.jarvis.api.Point +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable +import net.minecraft.client.MinecraftClient +import net.minecraft.client.option.KeyBinding +import net.minecraft.entity.player.PlayerEntity +import net.minecraft.text.Text +import net.minecraft.util.Arm +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.HudRenderEvent +import moe.nea.firmament.events.WorldKeyboardEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.errorBoundary + +object Fixes : FirmamentFeature { + override val identifier: String + get() = "fixes" + + object TConfig : ManagedConfig(identifier) { + val fixUnsignedPlayerSkins by toggle("player-skins") { true } + var autoSprint by toggle("auto-sprint") { false } + val autoSprintKeyBinding by keyBindingWithDefaultUnbound("auto-sprint-keybinding") + val autoSprintHud by position("auto-sprint-hud", 80, 10) { Point(0.0, 1.0) } + val peekChat by keyBindingWithDefaultUnbound("peek-chat") + } + + override val config: ManagedConfig + get() = TConfig + + fun handleIsPressed( + keyBinding: KeyBinding, + cir: CallbackInfoReturnable<Boolean> + ) { + if (keyBinding === MinecraftClient.getInstance().options.sprintKey && TConfig.autoSprint && MC.player?.isSprinting != true) + cir.returnValue = true + } + + @Subscribe + fun onRenderHud(it: HudRenderEvent) { + if (!TConfig.autoSprintKeyBinding.isBound) return + it.context.matrices.push() + TConfig.autoSprintHud.applyTransformations(it.context.matrices) + it.context.drawText( + MC.font, Text.translatable( + if (TConfig.autoSprint) + "firmament.fixes.auto-sprint.on" + else if (MC.player?.isSprinting == true) + "firmament.fixes.auto-sprint.sprinting" + else + "firmament.fixes.auto-sprint.not-sprinting" + ), 0, 0, -1, false + ) + it.context.matrices.pop() + } + + @Subscribe + fun onWorldKeyboard(it: WorldKeyboardEvent) { + if (it.matches(TConfig.autoSprintKeyBinding)) { + TConfig.autoSprint = !TConfig.autoSprint + } + } + + fun shouldPeekChat(): Boolean { + return TConfig.peekChat.isPressed(atLeast = true) + } +} diff --git a/src/main/kotlin/features/inventory/CraftingOverlay.kt b/src/main/kotlin/features/inventory/CraftingOverlay.kt new file mode 100644 index 0000000..031ef78 --- /dev/null +++ b/src/main/kotlin/features/inventory/CraftingOverlay.kt @@ -0,0 +1,66 @@ + + +package moe.nea.firmament.features.inventory + +import net.minecraft.client.gui.screen.ingame.GenericContainerScreen +import net.minecraft.item.ItemStack +import net.minecraft.util.Formatting +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.SlotRenderEvents +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.rei.FirmamentReiPlugin.Companion.asItemEntry +import moe.nea.firmament.rei.SBItemEntryDefinition +import moe.nea.firmament.rei.recipes.SBCraftingRecipe +import moe.nea.firmament.util.MC + +object CraftingOverlay : FirmamentFeature { + + private var screen: GenericContainerScreen? = null + private var recipe: SBCraftingRecipe? = null + private val craftingOverlayIndices = listOf( + 10, 11, 12, + 19, 20, 21, + 28, 29, 30, + ) + + + fun setOverlay(screen: GenericContainerScreen, recipe: SBCraftingRecipe) { + this.screen = screen + this.recipe = recipe + } + + override val identifier: String + get() = "crafting-overlay" + + @Subscribe + fun onSlotRender(event: SlotRenderEvents.After) { + val slot = event.slot + val recipe = this.recipe ?: return + if (slot.inventory != screen?.screenHandler?.inventory) return + val recipeIndex = craftingOverlayIndices.indexOf(slot.index) + if (recipeIndex < 0) return + val expectedItem = recipe.neuRecipe.inputs[recipeIndex] + val actualStack = slot.stack ?: ItemStack.EMPTY!! + val actualEntry = SBItemEntryDefinition.getEntry(actualStack).value + if ((actualEntry.skyblockId.neuItem != expectedItem.itemId || actualEntry.getStackSize() < expectedItem.amount) && expectedItem.amount.toInt() != 0) { + event.context.fill( + event.slot.x, + event.slot.y, + event.slot.x + 16, + event.slot.y + 16, + 0x80FF0000.toInt() + ) + } + if (!slot.hasStack()) { + val itemStack = SBItemEntryDefinition.getEntry(expectedItem).asItemEntry().value + event.context.drawItem(itemStack, event.slot.x, event.slot.y) + event.context.drawItemInSlot( + MC.font, + itemStack, + event.slot.x, + event.slot.y, + "${Formatting.RED}${expectedItem.amount.toInt()}" + ) + } + } +} diff --git a/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt b/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt new file mode 100644 index 0000000..566a813 --- /dev/null +++ b/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt @@ -0,0 +1,85 @@ + + +package moe.nea.firmament.features.inventory + +import java.awt.Color +import net.minecraft.client.gui.DrawContext +import net.minecraft.item.ItemStack +import net.minecraft.util.Formatting +import net.minecraft.util.Identifier +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.HotbarItemRenderEvent +import moe.nea.firmament.events.SlotRenderEvents +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.item.loreAccordingToNbt +import moe.nea.firmament.util.lastNotNullOfOrNull +import moe.nea.firmament.util.memoize +import moe.nea.firmament.util.memoizeIdentity +import moe.nea.firmament.util.unformattedString + +object ItemRarityCosmetics : FirmamentFeature { + override val identifier: String + get() = "item-rarity-cosmetics" + + object TConfig : ManagedConfig(identifier) { + val showItemRarityBackground by toggle("background") { false } + val showItemRarityInHotbar by toggle("background-hotbar") { false } + } + + override val config: ManagedConfig + get() = TConfig + + private val rarityToColor = mapOf( + "UNCOMMON" to Formatting.GREEN, + "COMMON" to Formatting.WHITE, + "RARE" to Formatting.DARK_BLUE, + "EPIC" to Formatting.DARK_PURPLE, + "LEGENDARY" to Formatting.GOLD, + "LEGENJERRY" to Formatting.GOLD, + "MYTHIC" to Formatting.LIGHT_PURPLE, + "DIVINE" to Formatting.BLUE, + "SPECIAL" to Formatting.DARK_RED, + "SUPREME" to Formatting.DARK_RED, + ).mapValues { + val c = Color(it.value.colorValue!!) + Triple(c.red / 255F, c.green / 255F, c.blue / 255F) + } + + private fun getSkyblockRarity0(itemStack: ItemStack): Triple<Float, Float, Float>? { + return itemStack.loreAccordingToNbt.lastNotNullOfOrNull { + val entry = it.unformattedString + rarityToColor.entries.find { (k, v) -> k in entry }?.value + } + } + + val getSkyblockRarity = ::getSkyblockRarity0.memoizeIdentity(100) + + + fun drawItemStackRarity(drawContext: DrawContext, x: Int, y: Int, item: ItemStack) { + val (r, g, b) = getSkyblockRarity(item) ?: return + drawContext.drawSprite( + x, y, + 0, + 16, 16, + MC.guiAtlasManager.getSprite(Identifier.of("firmament:item_rarity_background")), + r, g, b, 1F + ) + } + + + @Subscribe + fun onRenderSlot(it: SlotRenderEvents.Before) { + if (!TConfig.showItemRarityBackground) return + val stack = it.slot.stack ?: return + drawItemStackRarity(it.context, it.slot.x, it.slot.y, stack) + } + + @Subscribe + fun onRenderHotbarItem(it: HotbarItemRenderEvent) { + if (!TConfig.showItemRarityInHotbar) return + val stack = it.item + drawItemStackRarity(it.context, it.x, it.y, stack) + } +} diff --git a/src/main/kotlin/features/inventory/PriceData.kt b/src/main/kotlin/features/inventory/PriceData.kt new file mode 100644 index 0000000..c61f8e8 --- /dev/null +++ b/src/main/kotlin/features/inventory/PriceData.kt @@ -0,0 +1,51 @@ + + +package moe.nea.firmament.features.inventory + +import net.minecraft.text.Text +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ItemTooltipEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.repo.HypixelStaticData +import moe.nea.firmament.util.FirmFormatters +import moe.nea.firmament.util.skyBlockId + +object PriceData : FirmamentFeature { + override val identifier: String + get() = "price-data" + + object TConfig : ManagedConfig(identifier) { + val tooltipEnabled by toggle("enable-always") { true } + val enableKeybinding by keyBindingWithDefaultUnbound("enable-keybind") + } + + override val config get() = TConfig + + @Subscribe + fun onItemTooltip(it: ItemTooltipEvent) { + if (!TConfig.tooltipEnabled && !TConfig.enableKeybinding.isPressed()) { + return + } + val sbId = it.stack.skyBlockId + val bazaarData = HypixelStaticData.bazaarData[sbId] + val lowestBin = HypixelStaticData.lowestBin[sbId] + if (bazaarData != null) { + it.lines.add(Text.literal("")) + it.lines.add( + Text.stringifiedTranslatable("firmament.tooltip.bazaar.sell-order", + FirmFormatters.formatCommas(bazaarData.quickStatus.sellPrice, 1)) + ) + it.lines.add( + Text.stringifiedTranslatable("firmament.tooltip.bazaar.buy-order", + FirmFormatters.formatCommas(bazaarData.quickStatus.buyPrice, 1)) + ) + } else if (lowestBin != null) { + it.lines.add(Text.literal("")) + it.lines.add( + Text.stringifiedTranslatable("firmament.tooltip.ah.lowestbin", + FirmFormatters.formatCommas(lowestBin, 1)) + ) + } + } +} diff --git a/src/main/kotlin/features/inventory/SaveCursorPosition.kt b/src/main/kotlin/features/inventory/SaveCursorPosition.kt new file mode 100644 index 0000000..1c55753 --- /dev/null +++ b/src/main/kotlin/features/inventory/SaveCursorPosition.kt @@ -0,0 +1,66 @@ + + +package moe.nea.firmament.features.inventory + +import kotlin.math.absoluteValue +import kotlin.time.Duration.Companion.milliseconds +import net.minecraft.client.util.InputUtil +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.TimeMark +import moe.nea.firmament.util.assertNotNullOr + +object SaveCursorPosition : FirmamentFeature { + override val identifier: String + get() = "save-cursor-position" + + object TConfig : ManagedConfig(identifier) { + val enable by toggle("enable") { true } + val tolerance by duration("tolerance", 10.milliseconds, 5000.milliseconds) { 500.milliseconds } + } + + override val config: TConfig + get() = TConfig + + var savedPositionedP1: Pair<Double, Double>? = null + var savedPosition: SavedPosition? = null + + data class SavedPosition( + val middle: Pair<Double, Double>, + val cursor: Pair<Double, Double>, + val savedAt: TimeMark = TimeMark.now() + ) + + @JvmStatic + fun saveCursorOriginal(positionedX: Double, positionedY: Double) { + savedPositionedP1 = Pair(positionedX, positionedY) + } + + @JvmStatic + fun loadCursor(middleX: Double, middleY: Double): Pair<Double, Double>? { + if (!TConfig.enable) return null + val lastPosition = savedPosition?.takeIf { it.savedAt.passedTime() < TConfig.tolerance } + savedPosition = null + if (lastPosition != null && + (lastPosition.middle.first - middleX).absoluteValue < 1 && + (lastPosition.middle.second - middleY).absoluteValue < 1 + ) { + InputUtil.setCursorParameters( + MC.window.handle, + InputUtil.GLFW_CURSOR_NORMAL, + lastPosition.cursor.first, + lastPosition.cursor.second + ) + return lastPosition.cursor + } + return null + } + + @JvmStatic + fun saveCursorMiddle(middleX: Double, middleY: Double) { + if (!TConfig.enable) return + val cursorPos = assertNotNullOr(savedPositionedP1) { return } + savedPosition = SavedPosition(Pair(middleX, middleY), cursorPos) + } +} diff --git a/src/main/kotlin/features/inventory/SlotLocking.kt b/src/main/kotlin/features/inventory/SlotLocking.kt new file mode 100644 index 0000000..a50d8fb --- /dev/null +++ b/src/main/kotlin/features/inventory/SlotLocking.kt @@ -0,0 +1,203 @@ + + +@file:UseSerializers(DashlessUUIDSerializer::class) + +package moe.nea.firmament.features.inventory + +import com.mojang.blaze3d.systems.RenderSystem +import java.util.UUID +import org.lwjgl.glfw.GLFW +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.serializer +import net.minecraft.client.gui.screen.ingame.HandledScreen +import net.minecraft.entity.player.PlayerInventory +import net.minecraft.screen.GenericContainerScreenHandler +import net.minecraft.screen.slot.SlotActionType +import net.minecraft.util.Identifier +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.HandledScreenKeyPressedEvent +import moe.nea.firmament.events.IsSlotProtectedEvent +import moe.nea.firmament.events.SlotRenderEvents +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.keybindings.SavedKeyBinding +import moe.nea.firmament.mixins.accessor.AccessorHandledScreen +import moe.nea.firmament.util.CommonSoundEffects +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SBData +import moe.nea.firmament.util.SkyBlockIsland +import moe.nea.firmament.util.data.ProfileSpecificDataHolder +import moe.nea.firmament.util.item.displayNameAccordingToNbt +import moe.nea.firmament.util.item.loreAccordingToNbt +import moe.nea.firmament.util.json.DashlessUUIDSerializer +import moe.nea.firmament.util.skyblockUUID +import moe.nea.firmament.util.unformattedString + +object SlotLocking : FirmamentFeature { + override val identifier: String + get() = "slot-locking" + + @Serializable + data class Data( + val lockedSlots: MutableSet<Int> = mutableSetOf(), + val lockedSlotsRift: MutableSet<Int> = mutableSetOf(), + + val lockedUUIDs: MutableSet<UUID> = mutableSetOf(), + ) + + object TConfig : ManagedConfig(identifier) { + val lockSlot by keyBinding("lock") { GLFW.GLFW_KEY_L } + val lockUUID by keyBindingWithOutDefaultModifiers("lock-uuid") { + SavedKeyBinding(GLFW.GLFW_KEY_L, shift = true) + } + } + + override val config: TConfig + get() = TConfig + + object DConfig : ProfileSpecificDataHolder<Data>(serializer(), "locked-slots", ::Data) + + val lockedUUIDs get() = DConfig.data?.lockedUUIDs + + val lockedSlots + get() = when (SBData.skyblockLocation) { + SkyBlockIsland.RIFT -> DConfig.data?.lockedSlotsRift + null -> null + else -> DConfig.data?.lockedSlots + } + + fun isSalvageScreen(screen: HandledScreen<*>?): Boolean { + if (screen == null) return false + return screen.title.unformattedString.contains("Salvage Item") + } + + fun isTradeScreen(screen: HandledScreen<*>?): Boolean { + if (screen == null) return false + val handler = screen.screenHandler as? GenericContainerScreenHandler ?: return false + if (handler.inventory.size() < 9) return false + val middlePane = handler.inventory.getStack(handler.inventory.size() - 5) + if (middlePane == null) return false + return middlePane.displayNameAccordingToNbt?.unformattedString == "⇦ Your stuff" + } + + fun isNpcShop(screen: HandledScreen<*>?): Boolean { + if (screen == null) return false + val handler = screen.screenHandler as? GenericContainerScreenHandler ?: return false + if (handler.inventory.size() < 9) return false + val sellItem = handler.inventory.getStack(handler.inventory.size() - 5) + if (sellItem == null) return false + if (sellItem.displayNameAccordingToNbt?.unformattedString == "Sell Item") return true + val lore = sellItem.loreAccordingToNbt + return (lore.lastOrNull() ?: return false).unformattedString == "Click to buyback!" + } + + @Subscribe + fun onSalvageProtect(event: IsSlotProtectedEvent) { + if (event.slot == null) return + if (!event.slot.hasStack()) return + if (event.slot.stack.displayNameAccordingToNbt?.unformattedString != "Salvage Items") return + val inv = event.slot.inventory + var anyBlocked = false + for (i in 0 until event.slot.index) { + val stack = inv.getStack(i) + if (IsSlotProtectedEvent.shouldBlockInteraction(null, SlotActionType.THROW, stack)) + anyBlocked = true + } + if (anyBlocked) { + event.protectSilent() + } + } + + @Subscribe + fun onProtectUuidItems(event: IsSlotProtectedEvent) { + val doesNotDeleteItem = event.actionType == SlotActionType.SWAP + || event.actionType == SlotActionType.PICKUP + || event.actionType == SlotActionType.QUICK_MOVE + || event.actionType == SlotActionType.QUICK_CRAFT + || event.actionType == SlotActionType.CLONE + || event.actionType == SlotActionType.PICKUP_ALL + val isSellOrTradeScreen = + isNpcShop(MC.handledScreen) || isTradeScreen(MC.handledScreen) || isSalvageScreen(MC.handledScreen) + if ((!isSellOrTradeScreen || event.slot?.inventory !is PlayerInventory) + && doesNotDeleteItem + ) return + val stack = event.itemStack ?: return + val uuid = stack.skyblockUUID ?: return + if (uuid in (lockedUUIDs ?: return)) { + event.protect() + } + } + + @Subscribe + fun onProtectSlot(it: IsSlotProtectedEvent) { + if (it.slot != null && it.slot.inventory is PlayerInventory && it.slot.index in (lockedSlots ?: setOf())) { + it.protect() + } + } + + @Subscribe + fun onLockUUID(it: HandledScreenKeyPressedEvent) { + if (!it.matches(TConfig.lockUUID)) return + val inventory = MC.handledScreen ?: return + inventory as AccessorHandledScreen + + val slot = inventory.focusedSlot_Firmament ?: return + val stack = slot.stack ?: return + val uuid = stack.skyblockUUID ?: return + val lockedUUIDs = lockedUUIDs ?: return + if (uuid in lockedUUIDs) { + lockedUUIDs.remove(uuid) + } else { + lockedUUIDs.add(uuid) + } + DConfig.markDirty() + CommonSoundEffects.playSuccess() + it.cancel() + } + + @Subscribe + fun onLockSlot(it: HandledScreenKeyPressedEvent) { + if (!it.matches(TConfig.lockSlot)) return + val inventory = MC.handledScreen ?: return + inventory as AccessorHandledScreen + + val slot = inventory.focusedSlot_Firmament ?: return + val lockedSlots = lockedSlots ?: return + if (slot.inventory is PlayerInventory) { + if (slot.index in lockedSlots) { + lockedSlots.remove(slot.index) + } else { + lockedSlots.add(slot.index) + } + DConfig.markDirty() + CommonSoundEffects.playSuccess() + } + } + + @Subscribe + fun onRenderSlotOverlay(it: SlotRenderEvents.After) { + val isSlotLocked = it.slot.inventory is PlayerInventory && it.slot.index in (lockedSlots ?: setOf()) + val isUUIDLocked = (it.slot.stack?.skyblockUUID) in (lockedUUIDs ?: setOf()) + if (isSlotLocked || isUUIDLocked) { + RenderSystem.disableDepthTest() + it.context.drawSprite( + it.slot.x, it.slot.y, 0, + 16, 16, + MC.guiAtlasManager.getSprite( + when { + isSlotLocked -> + (Identifier.of("firmament:slot_locked")) + + isUUIDLocked -> + (Identifier.of("firmament:uuid_locked")) + + else -> + error("unreachable") + } + ) + ) + RenderSystem.enableDepthTest() + } + } +} diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButton.kt b/src/main/kotlin/features/inventory/buttons/InventoryButton.kt new file mode 100644 index 0000000..539edf2 --- /dev/null +++ b/src/main/kotlin/features/inventory/buttons/InventoryButton.kt @@ -0,0 +1,85 @@ + + +package moe.nea.firmament.features.inventory.buttons + +import com.mojang.brigadier.StringReader +import me.shedaniel.math.Dimension +import me.shedaniel.math.Point +import me.shedaniel.math.Rectangle +import kotlinx.serialization.Serializable +import net.minecraft.client.gui.DrawContext +import net.minecraft.command.CommandRegistryAccess +import net.minecraft.command.argument.ItemStackArgumentType +import net.minecraft.item.ItemStack +import net.minecraft.resource.featuretoggle.FeatureFlags +import net.minecraft.util.Identifier +import moe.nea.firmament.repo.ItemCache.asItemStack +import moe.nea.firmament.repo.RepoManager +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SkyblockId +import moe.nea.firmament.util.memoize + +@Serializable +data class InventoryButton( + var x: Int, + var y: Int, + var anchorRight: Boolean, + var anchorBottom: Boolean, + var icon: String? = "", + var command: String? = "", +) { + companion object { + val itemStackParser by lazy { + ItemStackArgumentType.itemStack(CommandRegistryAccess.of(MC.defaultRegistries, + FeatureFlags.VANILLA_FEATURES)) + } + val dimensions = Dimension(18, 18) + val getItemForName = ::getItemForName0.memoize(1024) + fun getItemForName0(icon: String): ItemStack { + val repoItem = RepoManager.getNEUItem(SkyblockId(icon)) + var itemStack = repoItem.asItemStack(idHint = SkyblockId(icon)) + if (repoItem == null) { + val giveSyntaxItem = if (icon.startsWith("/give") || icon.startsWith("give")) + icon.split(" ", limit = 3).getOrNull(2) ?: icon + else icon + val componentItem = + runCatching { + itemStackParser.parse(StringReader(giveSyntaxItem)).createStack(1, false) + }.getOrNull() + if (componentItem != null) + itemStack = componentItem + } + return itemStack + } + } + + fun render(context: DrawContext) { + context.drawSprite( + 0, + 0, + 0, + dimensions.width, + dimensions.height, + MC.guiAtlasManager.getSprite(Identifier.of("firmament:inventory_button_background")) + ) + context.drawItem(getItem(), 1, 1) + } + + fun isValid() = !icon.isNullOrBlank() && !command.isNullOrBlank() + + fun getPosition(guiRect: Rectangle): Point { + return Point( + (if (anchorRight) guiRect.maxX else guiRect.minX) + x, + (if (anchorBottom) guiRect.maxY else guiRect.minY) + y, + ) + } + + fun getBounds(guiRect: Rectangle): Rectangle { + return Rectangle(getPosition(guiRect), dimensions) + } + + fun getItem(): ItemStack { + return getItemForName(icon ?: "") + } + +} diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt new file mode 100644 index 0000000..c57563e --- /dev/null +++ b/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt @@ -0,0 +1,184 @@ + + +package moe.nea.firmament.features.inventory.buttons + +import io.github.notenoughupdates.moulconfig.common.IItemStack +import io.github.notenoughupdates.moulconfig.platform.ModernItemStack +import io.github.notenoughupdates.moulconfig.xml.Bind +import me.shedaniel.math.Point +import me.shedaniel.math.Rectangle +import org.lwjgl.glfw.GLFW +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.widget.ButtonWidget +import net.minecraft.client.util.InputUtil +import net.minecraft.text.Text +import net.minecraft.util.math.MathHelper +import net.minecraft.util.math.Vec2f +import moe.nea.firmament.util.ClipboardUtils +import moe.nea.firmament.util.FragmentGuiScreen +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.MoulConfigUtils + +class InventoryButtonEditor( + val lastGuiRect: Rectangle, +) : FragmentGuiScreen() { + inner class Editor(val originalButton: InventoryButton) { + @field:Bind + var command: String = originalButton.command ?: "" + + @field:Bind + var icon: String = originalButton.icon ?: "" + + @Bind + fun getItemIcon(): IItemStack { + save() + return ModernItemStack.of(InventoryButton.getItemForName(icon)) + } + + @Bind + fun delete() { + buttons.removeIf { it === originalButton } + popup = null + } + + fun save() { + originalButton.icon = icon + originalButton.command = command + } + } + + var buttons: MutableList<InventoryButton> = + InventoryButtons.DConfig.data.buttons.map { it.copy() }.toMutableList() + + override fun close() { + InventoryButtons.DConfig.data.buttons = buttons + InventoryButtons.DConfig.markDirty() + super.close() + } + + override fun init() { + super.init() + addDrawableChild( + ButtonWidget.builder(Text.translatable("firmament.inventory-buttons.load-preset")) { + val t = ClipboardUtils.getTextContents() + val newButtons = InventoryButtonTemplates.loadTemplate(t) + if (newButtons != null) + buttons = newButtons.toMutableList() + } + .position(lastGuiRect.minX + 10, lastGuiRect.minY + 35) + .width(lastGuiRect.width - 20) + .build() + ) + addDrawableChild( + ButtonWidget.builder(Text.translatable("firmament.inventory-buttons.save-preset")) { + ClipboardUtils.setTextContent(InventoryButtonTemplates.saveTemplate(buttons)) + } + .position(lastGuiRect.minX + 10, lastGuiRect.minY + 60) + .width(lastGuiRect.width - 20) + .build() + ) + } + + override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + super.render(context, mouseX, mouseY, delta) + context.matrices.push() + context.matrices.translate(0f, 0f, -10f) + context.fill(lastGuiRect.minX, lastGuiRect.minY, lastGuiRect.maxX, lastGuiRect.maxY, -1) + context.setShaderColor(1f, 1f, 1f, 1f) + context.matrices.pop() + for (button in buttons) { + val buttonPosition = button.getBounds(lastGuiRect) + context.matrices.push() + context.matrices.translate(buttonPosition.minX.toFloat(), buttonPosition.minY.toFloat(), 0F) + button.render(context) + context.matrices.pop() + } + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + if (super.keyPressed(keyCode, scanCode, modifiers)) return true + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + close() + return true + } + return false + } + + override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean { + if (super.mouseReleased(mouseX, mouseY, button)) return true + val clickedButton = buttons.firstOrNull { it.getBounds(lastGuiRect).contains(Point(mouseX, mouseY)) } + if (clickedButton != null && !justPerformedAClickAction) { + createPopup(MoulConfigUtils.loadGui("button_editor_fragment", Editor(clickedButton)), Point(mouseX, mouseY)) + return true + } + justPerformedAClickAction = false + lastDraggedButton = null + return false + } + + override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, deltaX: Double, deltaY: Double): Boolean { + if (super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) return true + + if (initialDragMousePosition.distanceSquared(Vec2f(mouseX.toFloat(), mouseY.toFloat())) >= 4 * 4) { + initialDragMousePosition = Vec2f(-10F, -10F) + lastDraggedButton?.let { dragging -> + justPerformedAClickAction = true + val (anchorRight, anchorBottom, offsetX, offsetY) = getCoordsForMouse(mouseX.toInt(), mouseY.toInt()) + ?: return true + dragging.x = offsetX + dragging.y = offsetY + dragging.anchorRight = anchorRight + dragging.anchorBottom = anchorBottom + } + } + return false + } + + var lastDraggedButton: InventoryButton? = null + var justPerformedAClickAction = false + var initialDragMousePosition = Vec2f(-10F, -10F) + + data class AnchoredCoords( + val anchorRight: Boolean, + val anchorBottom: Boolean, + val offsetX: Int, + val offsetY: Int, + ) + + fun getCoordsForMouse(mx: Int, my: Int): AnchoredCoords? { + if (lastGuiRect.contains(mx, my) || lastGuiRect.contains( + Point( + mx + InventoryButton.dimensions.width, + my + InventoryButton.dimensions.height, + ) + ) + ) return null + + val anchorRight = mx > lastGuiRect.maxX + val anchorBottom = my > lastGuiRect.maxY + var offsetX = mx - if (anchorRight) lastGuiRect.maxX else lastGuiRect.minX + var offsetY = my - if (anchorBottom) lastGuiRect.maxY else lastGuiRect.minY + if (InputUtil.isKeyPressed(MC.window.handle, InputUtil.GLFW_KEY_LEFT_SHIFT)) { + offsetX = MathHelper.floor(offsetX / 20F) * 20 + offsetY = MathHelper.floor(offsetY / 20F) * 20 + } + return AnchoredCoords(anchorRight, anchorBottom, offsetX, offsetY) + } + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + if (super.mouseClicked(mouseX, mouseY, button)) return true + val clickedButton = buttons.firstOrNull { it.getBounds(lastGuiRect).contains(Point(mouseX, mouseY)) } + if (clickedButton != null) { + lastDraggedButton = clickedButton + initialDragMousePosition = Vec2f(mouseX.toFloat(), mouseY.toFloat()) + return true + } + val mx = mouseX.toInt() + val my = mouseY.toInt() + val (anchorRight, anchorBottom, offsetX, offsetY) = getCoordsForMouse(mx, my) ?: return true + buttons.add(InventoryButton(offsetX, offsetY, anchorRight, anchorBottom, null, null)) + justPerformedAClickAction = true + return true + } + +} diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt new file mode 100644 index 0000000..99b544b --- /dev/null +++ b/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt @@ -0,0 +1,35 @@ + + +package moe.nea.firmament.features.inventory.buttons + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import net.minecraft.text.Text +import moe.nea.firmament.Firmament +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.TemplateUtil + +object InventoryButtonTemplates { + + val legacyPrefix = "NEUBUTTONS/" + val modernPrefix = "MAYBEONEDAYIWILLHAVEMYOWNFORMAT" + + fun loadTemplate(t: String): List<InventoryButton>? { + val buttons = TemplateUtil.maybeDecodeTemplate<List<String>>(legacyPrefix, t) ?: return null + return buttons.mapNotNull { + try { + Firmament.json.decodeFromString<InventoryButton>(it).also { + if (it.icon?.startsWith("extra:") == true || it.command?.any { it.isLowerCase() } == true) { + MC.sendChat(Text.translatable("firmament.inventory-buttons.import-failed")) + } + } + } catch (e: Exception) { + null + } + } + } + + fun saveTemplate(buttons: List<InventoryButton>): String { + return TemplateUtil.encodeTemplate(legacyPrefix, buttons.map { Firmament.json.encodeToString(it) }) + } +} diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt new file mode 100644 index 0000000..fa90d21 --- /dev/null +++ b/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt @@ -0,0 +1,88 @@ + + +package moe.nea.firmament.features.inventory.buttons + +import me.shedaniel.math.Rectangle +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.HandledScreenClickEvent +import moe.nea.firmament.events.HandledScreenForegroundEvent +import moe.nea.firmament.events.HandledScreenPushREIEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.ScreenUtil +import moe.nea.firmament.util.data.DataHolder +import moe.nea.firmament.util.getRectangle + +object InventoryButtons : FirmamentFeature { + override val identifier: String + get() = "inventory-buttons" + + object TConfig : ManagedConfig(identifier) { + val _openEditor by button("open-editor") { + openEditor() + } + } + + object DConfig : DataHolder<Data>(serializer(), identifier, ::Data) + + @Serializable + data class Data( + var buttons: MutableList<InventoryButton> = mutableListOf() + ) + + + override val config: ManagedConfig + get() = TConfig + + fun getValidButtons() = DConfig.data.buttons.asSequence().filter { it.isValid() } + + @Subscribe + fun onRectangles(it: HandledScreenPushREIEvent) { + val bounds = it.screen.getRectangle() + for (button in getValidButtons()) { + val buttonBounds = button.getBounds(bounds) + it.block(buttonBounds) + } + } + + @Subscribe + fun onClickScreen(it: HandledScreenClickEvent) { + val bounds = it.screen.getRectangle() + for (button in getValidButtons()) { + val buttonBounds = button.getBounds(bounds) + if (buttonBounds.contains(it.mouseX, it.mouseY)) { + MC.sendCommand(button.command!! /* non null invariant covered by getValidButtons */) + break + } + } + } + + @Subscribe + fun onRenderForeground(it: HandledScreenForegroundEvent) { + val bounds = it.screen.getRectangle() + for (button in getValidButtons()) { + val buttonBounds = button.getBounds(bounds) + it.context.matrices.push() + it.context.matrices.translate(buttonBounds.minX.toFloat(), buttonBounds.minY.toFloat(), 0F) + button.render(it.context) + it.context.matrices.pop() + } + lastRectangle = bounds + } + + var lastRectangle: Rectangle? = null + fun openEditor() { + ScreenUtil.setScreenLater( + InventoryButtonEditor( + lastRectangle ?: Rectangle( + MC.window.scaledWidth / 2 - 100, + MC.window.scaledHeight / 2 - 100, + 200, 200, + ) + ) + ) + } +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt new file mode 100644 index 0000000..1015578 --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt @@ -0,0 +1,53 @@ + + +package moe.nea.firmament.features.inventory.storageoverlay + +import net.minecraft.client.gui.screen.Screen +import net.minecraft.client.gui.screen.ingame.GenericContainerScreen +import net.minecraft.screen.GenericContainerScreenHandler +import moe.nea.firmament.util.ifMatches +import moe.nea.firmament.util.unformattedString + +/** + * A handle representing the state of the "server side" screens. + */ +sealed interface StorageBackingHandle { + + sealed interface HasBackingScreen { + val handler: GenericContainerScreenHandler + } + + /** + * The main storage overview is open. Clicking on a slot will open that page. This page is accessible via `/storage` + */ + data class Overview(override val handler: GenericContainerScreenHandler) : StorageBackingHandle, HasBackingScreen + + /** + * An individual storage page is open. This may be a backpack or an enderchest page. This page is accessible via + * the [Overview] or via `/ec <index + 1>` for enderchest pages. + */ + data class Page(override val handler: GenericContainerScreenHandler, val storagePageSlot: StoragePageSlot) : + StorageBackingHandle, HasBackingScreen + + companion object { + private val enderChestName = "^Ender Chest \\(([1-9])/[1-9]\\)$".toRegex() + private val backPackName = "^.+Backpack \\(Slot #([0-9]+)\\)$".toRegex() + + /** + * Parse a screen into a [StorageBackingHandle]. If this returns null it means that the screen is not + * representable as a [StorageBackingHandle], meaning another screen is open, for example the enderchest icon + * selection screen. + */ + fun fromScreen(screen: Screen?): StorageBackingHandle? { + if (screen == null) return null + if (screen !is GenericContainerScreen) return null + val title = screen.title.unformattedString + if (title == "Storage") return Overview(screen.screenHandler) + return title.ifMatches(enderChestName) { + Page(screen.screenHandler, StoragePageSlot.ofEnderChestPage(it.groupValues[1].toInt())) + } ?: title.ifMatches(backPackName) { + Page(screen.screenHandler, StoragePageSlot.ofBackPackPage(it.groupValues[1].toInt())) + } + } + } +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt new file mode 100644 index 0000000..7555c56 --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt @@ -0,0 +1,21 @@ + + +@file:UseSerializers(SortedMapSerializer::class) +package moe.nea.firmament.features.inventory.storageoverlay + +import java.util.SortedMap +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import moe.nea.firmament.util.SortedMapSerializer + +@Serializable +data class StorageData( + val storageInventories: SortedMap<StoragePageSlot, StorageInventory> = sortedMapOf() +) { + @Serializable + data class StorageInventory( + var title: String, + val slot: StoragePageSlot, + var inventory: VirtualInventory?, + ) +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt new file mode 100644 index 0000000..b615c73 --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt @@ -0,0 +1,154 @@ + + +package moe.nea.firmament.features.inventory.storageoverlay + +import java.util.SortedMap +import kotlinx.serialization.serializer +import net.minecraft.client.gui.screen.ingame.GenericContainerScreen +import net.minecraft.client.gui.screen.ingame.HandledScreen +import net.minecraft.entity.player.PlayerInventory +import net.minecraft.item.Items +import net.minecraft.network.packet.c2s.play.CloseHandledScreenC2SPacket +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ScreenChangeEvent +import moe.nea.firmament.events.SlotClickEvent +import moe.nea.firmament.events.TickEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.customgui.customGui +import moe.nea.firmament.util.data.ProfileSpecificDataHolder + +object StorageOverlay : FirmamentFeature { + + + object Data : ProfileSpecificDataHolder<StorageData>(serializer(), "storage-data", ::StorageData) + + override val identifier: String + get() = "storage-overlay" + + object TConfig : ManagedConfig(identifier) { + val alwaysReplace by toggle("always-replace") { true } + val columns by integer("rows", 1, 10) { 3 } + val scrollSpeed by integer("scroll-speed", 1, 50) { 10 } + val inverseScroll by toggle("inverse-scroll") { false } + val padding by integer("padding", 1, 20) { 5 } + val margin by integer("margin", 1, 60) { 20 } + } + + fun adjustScrollSpeed(amount: Double): Double { + return amount * TConfig.scrollSpeed * (if (TConfig.inverseScroll) 1 else -1) + } + + override val config: TConfig + get() = TConfig + + var lastStorageOverlay: StorageOverviewScreen? = null + var skipNextStorageOverlayBackflip = false + var currentHandler: StorageBackingHandle? = null + + @Subscribe + fun onTick(event: TickEvent) { + rememberContent(currentHandler ?: return) + } + + @Subscribe + fun onClick(event: SlotClickEvent) { + if (lastStorageOverlay != null && event.slot.inventory !is PlayerInventory && event.slot.index < 9 + && event.stack.item != Items.BLACK_STAINED_GLASS_PANE + ) { + skipNextStorageOverlayBackflip = true + } + } + + @Subscribe + fun onScreenChange(it: ScreenChangeEvent) { + if (it.old == null && it.new == null) return + val storageOverlayScreen = it.old as? StorageOverlayScreen + ?: ((it.old as? HandledScreen<*>)?.customGui as? StorageOverlayCustom)?.overview + var storageOverviewScreen = it.old as? StorageOverviewScreen + val screen = it.new as? GenericContainerScreen + val oldHandler = currentHandler + currentHandler = StorageBackingHandle.fromScreen(screen) + rememberContent(currentHandler) + if (storageOverviewScreen != null && oldHandler is StorageBackingHandle.HasBackingScreen) { + val player = MC.player + assert(player != null) + player?.networkHandler?.sendPacket(CloseHandledScreenC2SPacket(oldHandler.handler.syncId)) + if (player?.currentScreenHandler === oldHandler.handler) { + player.currentScreenHandler = player.playerScreenHandler + } + } + storageOverviewScreen = storageOverviewScreen ?: lastStorageOverlay + if (it.new == null && storageOverlayScreen != null && !storageOverlayScreen.isExiting) { + it.overrideScreen = storageOverlayScreen + return + } + if (storageOverviewScreen != null + && !storageOverviewScreen.isClosing + && (currentHandler is StorageBackingHandle.Overview || currentHandler == null) + ) { + if (skipNextStorageOverlayBackflip) { + skipNextStorageOverlayBackflip = false + } else { + it.overrideScreen = storageOverviewScreen + lastStorageOverlay = null + } + return + } + screen ?: return + screen.customGui = StorageOverlayCustom( + currentHandler ?: return, + screen, + storageOverlayScreen ?: (if (TConfig.alwaysReplace) StorageOverlayScreen() else return)) + } + + fun rememberContent(handler: StorageBackingHandle?) { + handler ?: return + // TODO: Make all of these functions work on deltas / updates instead of the entire contents + val data = Data.data?.storageInventories ?: return + when (handler) { + is StorageBackingHandle.Overview -> rememberStorageOverview(handler, data) + is StorageBackingHandle.Page -> rememberPage(handler, data) + } + Data.markDirty() + } + + private fun rememberStorageOverview( + handler: StorageBackingHandle.Overview, + data: SortedMap<StoragePageSlot, StorageData.StorageInventory> + ) { + for ((index, stack) in handler.handler.stacks.withIndex()) { + // Ignore unloaded item stacks + if (stack.isEmpty) continue + val slot = StoragePageSlot.fromOverviewSlotIndex(index) ?: continue + val isEmpty = stack.item in StorageOverviewScreen.emptyStorageSlotItems + if (slot in data) { + if (isEmpty) + data.remove(slot) + continue + } + if (!isEmpty) { + data[slot] = StorageData.StorageInventory(slot.defaultName(), slot, null) + } + } + } + + private fun rememberPage( + handler: StorageBackingHandle.Page, + data: SortedMap<StoragePageSlot, StorageData.StorageInventory> + ) { + // TODO: FIXME: FIXME NOW: Definitely don't copy all of this every tick into persistence + val newStacks = + VirtualInventory(handler.handler.stacks.take(handler.handler.rows * 9).drop(9).map { it.copy() }) + data.compute(handler.storagePageSlot) { slot, existingInventory -> + (existingInventory ?: StorageData.StorageInventory( + slot.defaultName(), + slot, + null + )).also { + it.inventory = newStacks + } + } + } +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt new file mode 100644 index 0000000..d0d9114 --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt @@ -0,0 +1,98 @@ + +package moe.nea.firmament.features.inventory.storageoverlay + +import me.shedaniel.math.Point +import me.shedaniel.math.Rectangle +import net.minecraft.client.MinecraftClient +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.screen.ingame.GenericContainerScreen +import net.minecraft.entity.player.PlayerInventory +import net.minecraft.screen.slot.Slot +import moe.nea.firmament.mixins.accessor.AccessorHandledScreen +import moe.nea.firmament.util.customgui.CustomGui + +class StorageOverlayCustom( + val handler: StorageBackingHandle, + val screen: GenericContainerScreen, + val overview: StorageOverlayScreen, +) : CustomGui() { + override fun onVoluntaryExit(): Boolean { + overview.isExiting = true + return super.onVoluntaryExit() + } + + override fun getBounds(): List<Rectangle> { + return overview.getBounds() + } + + override fun afterSlotRender(context: DrawContext, slot: Slot) { + if (slot.inventory !is PlayerInventory) + context.disableScissor() + } + + override fun beforeSlotRender(context: DrawContext, slot: Slot) { + if (slot.inventory !is PlayerInventory) + overview.createScissors(context) + } + + override fun onInit() { + overview.init(MinecraftClient.getInstance(), screen.width, screen.height) + overview.init() + screen as AccessorHandledScreen + screen.x_Firmament = overview.measurements.x + screen.y_Firmament = overview.measurements.y + screen.backgroundWidth_Firmament = overview.measurements.totalWidth + screen.backgroundHeight_Firmament = overview.measurements.totalHeight + } + + override fun isPointOverSlot(slot: Slot, xOffset: Int, yOffset: Int, pointX: Double, pointY: Double): Boolean { + if (!super.isPointOverSlot(slot, xOffset, yOffset, pointX, pointY)) + return false + if (slot.inventory !is PlayerInventory) { + if (!overview.getScrollPanelInner().contains(pointX, pointY)) + return false + } + return true + } + + override fun shouldDrawForeground(): Boolean { + return false + } + + override fun mouseClick(mouseX: Double, mouseY: Double, button: Int): Boolean { + return overview.mouseClicked(mouseX, mouseY, button, (handler as? StorageBackingHandle.Page)?.storagePageSlot) + } + + override fun render(drawContext: DrawContext, delta: Float, mouseX: Int, mouseY: Int) { + overview.drawBackgrounds(drawContext) + overview.drawPages(drawContext, + mouseX, + mouseY, + delta, + (handler as? StorageBackingHandle.Page)?.storagePageSlot, + screen.screenHandler.slots.take(screen.screenHandler.rows * 9).drop(9), + Point((screen as AccessorHandledScreen).x_Firmament, screen.y_Firmament)) + overview.drawScrollBar(drawContext) + } + + override fun moveSlot(slot: Slot) { + val index = slot.index + if (index in 0..<36) { + val (x, y) = overview.getPlayerInventorySlotPosition(index) + slot.x = x - (screen as AccessorHandledScreen).x_Firmament + slot.y = y - screen.y_Firmament + } else { + slot.x = -100000 + slot.y = -100000 + } + } + + override fun mouseScrolled( + mouseX: Double, + mouseY: Double, + horizontalAmount: Double, + verticalAmount: Double + ): Boolean { + return overview.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount) + } +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt new file mode 100644 index 0000000..13c6974 --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt @@ -0,0 +1,296 @@ + +package moe.nea.firmament.features.inventory.storageoverlay + +import me.shedaniel.math.Point +import me.shedaniel.math.Rectangle +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.screen.Screen +import net.minecraft.screen.slot.Slot +import net.minecraft.text.Text +import net.minecraft.util.Identifier +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.CommandEvent +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.ScreenUtil +import moe.nea.firmament.util.assertTrueOr + +class StorageOverlayScreen : Screen(Text.literal("")) { + + companion object { + val PLAYER_WIDTH = 184 + val PLAYER_HEIGHT = 91 + val PLAYER_Y_INSET = 3 + val SLOT_SIZE = 18 + val PADDING = 10 + val PAGE_WIDTH = SLOT_SIZE * 9 + val HOTBAR_X = 12 + val HOTBAR_Y = 67 + val MAIN_INVENTORY_Y = 9 + val SCROLL_BAR_WIDTH = 8 + val SCROLL_BAR_HEIGHT = 16 + } + + var isExiting: Boolean = false + var scroll: Float = 0F + var pageWidthCount = StorageOverlay.TConfig.columns + + inner class Measurements { + val innerScrollPanelWidth = PAGE_WIDTH * pageWidthCount + (pageWidthCount - 1) * PADDING + val overviewWidth = innerScrollPanelWidth + 3 * PADDING + SCROLL_BAR_WIDTH + val x = width / 2 - overviewWidth / 2 + val overviewHeight = minOf(3 * 18 * 6, height - PLAYER_HEIGHT - minOf(80, height / 10)) + val innerScrollPanelHeight = overviewHeight - PADDING * 2 + val y = height / 2 - (overviewHeight + PLAYER_HEIGHT) / 2 + val playerX = width / 2 - PLAYER_WIDTH / 2 + val playerY = y + overviewHeight - PLAYER_Y_INSET + val totalWidth = overviewWidth + val totalHeight = overviewHeight - PLAYER_Y_INSET + PLAYER_HEIGHT + } + + var measurements = Measurements() + + var lastRenderedInnerHeight = 0 + public override fun init() { + super.init() + pageWidthCount = StorageOverlay.TConfig.columns + .coerceAtMost((width - PADDING) / (PAGE_WIDTH + PADDING)) + .coerceAtLeast(1) + measurements = Measurements() + } + + override fun mouseScrolled( + mouseX: Double, + mouseY: Double, + horizontalAmount: Double, + verticalAmount: Double + ): Boolean { + scroll = (scroll + StorageOverlay.adjustScrollSpeed(verticalAmount)).toFloat() + .coerceAtMost(getMaxScroll()) + .coerceAtLeast(0F) + return true + } + + fun getMaxScroll() = lastRenderedInnerHeight.toFloat() - getScrollPanelInner().height + + val playerInventorySprite = Identifier.of("firmament:storageoverlay/player_inventory") + val upperBackgroundSprite = Identifier.of("firmament:storageoverlay/upper_background") + val slotRowSprite = Identifier.of("firmament:storageoverlay/storage_row") + val scrollbarBackground = Identifier.of("firmament:storageoverlay/scroll_bar_background") + val scrollbarKnob = Identifier.of("firmament:storageoverlay/scroll_bar_knob") + + override fun close() { + isExiting = true + super.close() + } + + override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + super.render(context, mouseX, mouseY, delta) + drawBackgrounds(context) + drawPages(context, mouseX, mouseY, delta, null, null, Point()) + drawScrollBar(context) + drawPlayerInventory(context, mouseX, mouseY, delta) + } + + fun getScrollbarPercentage(): Float { + return scroll / getMaxScroll() + } + + fun drawScrollBar(context: DrawContext) { + val sbRect = getScrollBarRect() + context.drawGuiTexture( + scrollbarBackground, + sbRect.minX, sbRect.minY, + sbRect.width, sbRect.height, + ) + context.drawGuiTexture( + scrollbarKnob, + sbRect.minX, sbRect.minY + (getScrollbarPercentage() * (sbRect.height - SCROLL_BAR_HEIGHT)).toInt(), + SCROLL_BAR_WIDTH, SCROLL_BAR_HEIGHT + ) + } + + fun drawBackgrounds(context: DrawContext) { + context.drawGuiTexture(upperBackgroundSprite, + measurements.x, + measurements.y, + 0, + measurements.overviewWidth, + measurements.overviewHeight) + context.drawGuiTexture(playerInventorySprite, + measurements.playerX, + measurements.playerY, + 0, + PLAYER_WIDTH, + PLAYER_HEIGHT) + } + + fun getPlayerInventorySlotPosition(int: Int): Pair<Int, Int> { + if (int < 9) { + return Pair(measurements.playerX + int * SLOT_SIZE + HOTBAR_X, HOTBAR_Y + measurements.playerY) + } + return Pair( + measurements.playerX + (int % 9) * SLOT_SIZE + HOTBAR_X, + measurements.playerY + (int / 9 - 1) * SLOT_SIZE + MAIN_INVENTORY_Y + ) + } + + fun drawPlayerInventory(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + val items = MC.player?.inventory?.main ?: return + items.withIndex().forEach { (index, item) -> + val (x, y) = getPlayerInventorySlotPosition(index) + context.drawItem(item, x, y, 0) + context.drawItemInSlot(textRenderer, item, x, y) + } + } + + fun getScrollBarRect(): Rectangle { + return Rectangle(measurements.x + PADDING + measurements.innerScrollPanelWidth + PADDING, + measurements.y + PADDING, + SCROLL_BAR_WIDTH, + measurements.innerScrollPanelHeight) + } + + fun getScrollPanelInner(): Rectangle { + return Rectangle(measurements.x + PADDING, + measurements.y + PADDING, + measurements.innerScrollPanelWidth, + measurements.innerScrollPanelHeight) + } + + fun createScissors(context: DrawContext) { + val rect = getScrollPanelInner() + context.enableScissor( + rect.minX, rect.minY, + rect.maxX, rect.maxY + ) + } + + fun drawPages( + context: DrawContext, mouseX: Int, mouseY: Int, delta: Float, + excluding: StoragePageSlot?, + slots: List<Slot>?, + slotOffset: Point + ) { + createScissors(context) + val data = StorageOverlay.Data.data ?: StorageData() + layoutedForEach(data) { rect, page, inventory -> + drawPage(context, + rect.x, + rect.y, + page, inventory, + if (excluding == page) slots else null, + slotOffset + ) + } + context.disableScissor() + } + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + return mouseClicked(mouseX, mouseY, button, null) + } + + fun mouseClicked(mouseX: Double, mouseY: Double, button: Int, activePage: StoragePageSlot?): Boolean { + if (getScrollPanelInner().contains(mouseX, mouseY)) { + val data = StorageOverlay.Data.data ?: StorageData() + layoutedForEach(data) { rect, page, _ -> + if (rect.contains(mouseX, mouseY) && activePage != page && button == 0) { + page.navigateTo() + return true + } + } + return false + } + val sbRect = getScrollBarRect() + if (sbRect.contains(mouseX, mouseY)) { + // TODO: support dragging of the mouse and such + val percentage = (mouseY - sbRect.getY()) / sbRect.getHeight() + scroll = (getMaxScroll() * percentage).toFloat() + mouseScrolled(0.0, 0.0, 0.0, 0.0) + return true + } + return false + } + + private inline fun layoutedForEach( + data: StorageData, + func: ( + rectangle: Rectangle, + page: StoragePageSlot, inventory: StorageData.StorageInventory, + ) -> Unit + ) { + var yOffset = -scroll.toInt() + var xOffset = 0 + var maxHeight = 0 + for ((page, inventory) in data.storageInventories.entries) { + val currentHeight = inventory.inventory?.let { it.rows * SLOT_SIZE + 4 + textRenderer.fontHeight } + ?: 18 + maxHeight = maxOf(maxHeight, currentHeight) + val rect = Rectangle( + measurements.x + PADDING + (PAGE_WIDTH + PADDING) * xOffset, + yOffset + measurements.y + PADDING, + PAGE_WIDTH, + currentHeight + ) + func(rect, page, inventory) + xOffset++ + if (xOffset >= pageWidthCount) { + yOffset += maxHeight + xOffset = 0 + maxHeight = 0 + } + } + lastRenderedInnerHeight = maxHeight + yOffset + scroll.toInt() + } + + fun drawPage( + context: DrawContext, + x: Int, + y: Int, + page: StoragePageSlot, + inventory: StorageData.StorageInventory, + slots: List<Slot>?, + slotOffset: Point, + ): Int { + val inv = inventory.inventory + if (inv == null) { + context.drawGuiTexture(upperBackgroundSprite, x, y, PAGE_WIDTH, 18) + context.drawText(textRenderer, + Text.literal("TODO: open this page"), + x + 4, + y + 4, + -1, + true) + return 18 + } + assertTrueOr(slots == null || slots.size == inv.stacks.size) { return 0 } + val name = page.defaultName() + context.drawText(textRenderer, Text.literal(name), x + 4, y + 2, + if (slots == null) 0xFFFFFFFF.toInt() else 0xFFFFFF00.toInt(), true) + context.drawGuiTexture(slotRowSprite, x, y + 4 + textRenderer.fontHeight, PAGE_WIDTH, inv.rows * SLOT_SIZE) + inv.stacks.forEachIndexed { index, stack -> + val slotX = (index % 9) * SLOT_SIZE + x + 1 + val slotY = (index / 9) * SLOT_SIZE + y + 4 + textRenderer.fontHeight + 1 + if (slots == null) { + context.drawItem(stack, slotX, slotY) + context.drawItemInSlot(textRenderer, stack, slotX, slotY) + } else { + val slot = slots[index] + slot.x = slotX - slotOffset.x + slot.y = slotY - slotOffset.y + } + } + return inv.rows * SLOT_SIZE + 4 + textRenderer.fontHeight + } + + fun getBounds(): List<Rectangle> { + return listOf( + Rectangle(measurements.x, + measurements.y, + measurements.overviewWidth, + measurements.overviewHeight), + Rectangle(measurements.playerX, + measurements.playerY, + PLAYER_WIDTH, + PLAYER_HEIGHT)) + } +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt new file mode 100644 index 0000000..2cbd54e --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt @@ -0,0 +1,123 @@ + + +package moe.nea.firmament.features.inventory.storageoverlay + +import org.lwjgl.glfw.GLFW +import kotlin.math.max +import net.minecraft.block.Blocks +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.screen.Screen +import net.minecraft.item.Item +import net.minecraft.item.Items +import net.minecraft.text.Text +import net.minecraft.util.DyeColor +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.toShedaniel + +class StorageOverviewScreen() : Screen(Text.empty()) { + companion object { + val emptyStorageSlotItems = listOf<Item>( + Blocks.RED_STAINED_GLASS_PANE.asItem(), + Blocks.BROWN_STAINED_GLASS_PANE.asItem(), + Items.GRAY_DYE + ) + val pageWidth get() = 19 * 9 + } + + val content = StorageOverlay.Data.data ?: StorageData() + var isClosing = false + + var scroll = 0 + var lastRenderedHeight = 0 + + override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + super.render(context, mouseX, mouseY, delta) + context.fill(0, 0, width, height, 0x90000000.toInt()) + layoutedForEach { (key, value), offsetX, offsetY -> + context.matrices.push() + context.matrices.translate(offsetX.toFloat(), offsetY.toFloat(), 0F) + renderStoragePage(context, value, mouseX - offsetX, mouseY - offsetY) + context.matrices.pop() + } + } + + inline fun layoutedForEach(onEach: (data: Pair<StoragePageSlot, StorageData.StorageInventory>, offsetX: Int, offsetY: Int) -> Unit) { + var offsetY = 0 + var currentMaxHeight = StorageOverlay.config.margin - StorageOverlay.config.padding - scroll + var totalHeight = -currentMaxHeight + content.storageInventories.onEachIndexed { index, (key, value) -> + val pageX = (index % StorageOverlay.config.columns) + if (pageX == 0) { + currentMaxHeight += StorageOverlay.config.padding + offsetY += currentMaxHeight + totalHeight += currentMaxHeight + currentMaxHeight = 0 + } + val xPosition = + width / 2 - (StorageOverlay.config.columns * (pageWidth + StorageOverlay.config.padding) - StorageOverlay.config.padding) / 2 + pageX * (pageWidth + StorageOverlay.config.padding) + onEach(Pair(key, value), xPosition, offsetY) + val height = getStorePageHeight(value) + currentMaxHeight = max(currentMaxHeight, height) + } + lastRenderedHeight = totalHeight + currentMaxHeight + } + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + layoutedForEach { (k, p), x, y -> + val rx = mouseX - x + val ry = mouseY - y + if (rx in (0.0..pageWidth.toDouble()) && ry in (0.0..getStorePageHeight(p).toDouble())) { + close() + StorageOverlay.lastStorageOverlay = this + k.navigateTo() + return true + } + } + return super.mouseClicked(mouseX, mouseY, button) + } + + fun getStorePageHeight(page: StorageData.StorageInventory): Int { + return page.inventory?.rows?.let { it * 19 + MC.font.fontHeight + 2 } ?: 60 + } + + override fun mouseScrolled( + mouseX: Double, + mouseY: Double, + horizontalAmount: Double, + verticalAmount: Double + ): Boolean { + scroll = + (scroll + StorageOverlay.adjustScrollSpeed(verticalAmount)).toInt() + .coerceAtMost(lastRenderedHeight - height + 2 * StorageOverlay.config.margin).coerceAtLeast(0) + return true + } + + private fun renderStoragePage(context: DrawContext, page: StorageData.StorageInventory, mouseX: Int, mouseY: Int) { + context.drawText(MC.font, page.title, 2, 2, -1, true) + val inventory = page.inventory + if (inventory == null) { + // TODO: Missing texture + context.fill(0, 0, pageWidth, 60, DyeColor.RED.toShedaniel().darker(4.0).color) + context.drawCenteredTextWithShadow(MC.font, Text.literal("Not loaded yet"), pageWidth / 2, 30, -1) + return + } + + for ((index, stack) in inventory.stacks.withIndex()) { + val x = (index % 9) * 19 + val y = (index / 9) * 19 + MC.font.fontHeight + 2 + if (((mouseX - x) in 0 until 18) && ((mouseY - y) in 0 until 18)) { + context.fill(x, y, x + 18, y + 18, 0x80808080.toInt()) + } else { + context.fill(x, y, x + 18, y + 18, 0x40808080.toInt()) + } + context.drawItem(stack, x + 1, y + 1) + context.drawItemInSlot(MC.font, stack, x + 1, y + 1) + } + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) + isClosing = true + return super.keyPressed(keyCode, scanCode, modifiers) + } +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt b/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt new file mode 100644 index 0000000..9259415 --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt @@ -0,0 +1,66 @@ + + +package moe.nea.firmament.features.inventory.storageoverlay + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import moe.nea.firmament.util.MC + +@Serializable(with = StoragePageSlot.Serializer::class) +data class StoragePageSlot(val index: Int) : Comparable<StoragePageSlot> { + object Serializer : KSerializer<StoragePageSlot> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StoragePageSlot", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): StoragePageSlot { + return StoragePageSlot(decoder.decodeInt()) + } + + override fun serialize(encoder: Encoder, value: StoragePageSlot) { + encoder.encodeInt(value.index) + } + } + + init { + assert(index in 0 until (3 * 9)) + } + + val isEnderChest get() = index < 9 + val isBackPack get() = !isEnderChest + val slotIndexInOverviewPage get() = if (isEnderChest) index + 9 else index + 18 + fun defaultName(): String = if (isEnderChest) "Ender Chest #${index + 1}" else "Backpack #${index - 9 + 1}" + + fun navigateTo() { + if (isBackPack) { + MC.sendCommand("backpack ${index - 9 + 1}") + } else { + MC.sendCommand("enderchest ${index + 1}") + } + } + + companion object { + fun fromOverviewSlotIndex(slot: Int): StoragePageSlot? { + if (slot in 9 until 18) return StoragePageSlot(slot - 9) + if (slot in 27 until 45) return StoragePageSlot(slot - 27 + 9) + return null + } + + fun ofEnderChestPage(slot: Int): StoragePageSlot { + assert(slot in 1..9) + return StoragePageSlot(slot - 1) + } + + fun ofBackPackPage(slot: Int): StoragePageSlot { + assert(slot in 1..18) + return StoragePageSlot(slot - 1 + 9) + } + } + + override fun compareTo(other: StoragePageSlot): Int { + return this.index - other.index + } +} diff --git a/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt b/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt new file mode 100644 index 0000000..e07df8a --- /dev/null +++ b/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt @@ -0,0 +1,65 @@ + + +package moe.nea.firmament.features.inventory.storageoverlay + +import io.ktor.util.decodeBase64Bytes +import io.ktor.util.encodeBase64 +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.minecraft.item.ItemStack +import net.minecraft.nbt.NbtCompound +import net.minecraft.nbt.NbtIo +import net.minecraft.nbt.NbtList +import net.minecraft.nbt.NbtOps +import net.minecraft.nbt.NbtSizeTracker + +@Serializable(with = VirtualInventory.Serializer::class) +data class VirtualInventory( + val stacks: List<ItemStack> +) { + val rows = stacks.size / 9 + + init { + assert(stacks.size % 9 == 0) + assert(stacks.size / 9 in 1..5) + } + + + object Serializer : KSerializer<VirtualInventory> { + const val INVENTORY = "INVENTORY" + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("VirtualInventory", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): VirtualInventory { + val s = decoder.decodeString() + val n = NbtIo.readCompressed(ByteArrayInputStream(s.decodeBase64Bytes()), NbtSizeTracker.of(100_000_000)) + val items = n.getList(INVENTORY, NbtCompound.COMPOUND_TYPE.toInt()) + return VirtualInventory(items.map { + it as NbtCompound + if (it.isEmpty) ItemStack.EMPTY + else runCatching { + ItemStack.CODEC.parse(NbtOps.INSTANCE, it).orThrow + }.getOrElse { ItemStack.EMPTY } + }) + } + + override fun serialize(encoder: Encoder, value: VirtualInventory) { + val list = NbtList() + value.stacks.forEach { + if (it.isEmpty) list.add(NbtCompound()) + else list.add(runCatching { ItemStack.CODEC.encode(it, NbtOps.INSTANCE, NbtCompound()).orThrow } + .getOrElse { NbtCompound() }) + } + val baos = ByteArrayOutputStream() + NbtIo.writeCompressed(NbtCompound().also { it.put(INVENTORY, list) }, baos) + encoder.encodeString(baos.toByteArray().encodeBase64()) + } + } +} diff --git a/src/main/kotlin/features/mining/Histogram.kt b/src/main/kotlin/features/mining/Histogram.kt new file mode 100644 index 0000000..ed48437 --- /dev/null +++ b/src/main/kotlin/features/mining/Histogram.kt @@ -0,0 +1,81 @@ + +package moe.nea.firmament.features.mining + +import java.util.* +import kotlin.time.Duration +import moe.nea.firmament.util.TimeMark + +class Histogram<T>( + val maxSize: Int, + val maxDuration: Duration, +) { + + data class OrderedTimestamp(val timestamp: TimeMark, val order: Int) : Comparable<OrderedTimestamp> { + override fun compareTo(other: OrderedTimestamp): Int { + val o = timestamp.compareTo(other.timestamp) + if (o != 0) return o + return order.compareTo(other.order) + } + } + + val size: Int get() = dataPoints.size + private val dataPoints: NavigableMap<OrderedTimestamp, T> = TreeMap() + + private var order = Int.MIN_VALUE + + fun record(entry: T, timestamp: TimeMark = TimeMark.now()) { + dataPoints[OrderedTimestamp(timestamp, order++)] = entry + trim() + } + + fun oldestUpdate(): TimeMark { + trim() + return if (dataPoints.isEmpty()) TimeMark.now() else dataPoints.firstKey().timestamp + } + + fun latestUpdate(): TimeMark { + trim() + return if (dataPoints.isEmpty()) TimeMark.farPast() else dataPoints.lastKey().timestamp + } + + fun averagePer(valueExtractor: (T) -> Double, perDuration: Duration): Double? { + return aggregate( + seed = 0.0, + operator = { accumulator, entry, _ -> accumulator + valueExtractor(entry) }, + finish = { sum, beginning, end -> + val timespan = end - beginning + if (timespan > perDuration) + sum / (timespan / perDuration) + else null + }) + } + + fun <V, R> aggregate( + seed: V, + operator: (V, T, TimeMark) -> V, + finish: (V, TimeMark, TimeMark) -> R + ): R? { + trim() + var accumulator = seed + var min: TimeMark? = null + var max: TimeMark? = null + dataPoints.forEach { (key, value) -> + max = key.timestamp + if (min == null) + min = key.timestamp + accumulator = operator(accumulator, value, key.timestamp) + } + if (min == null) + return null + return finish(accumulator, min!!, max!!) + } + + private fun trim() { + while (maxSize < dataPoints.size) { + dataPoints.pollFirstEntry() + } + dataPoints.headMap(OrderedTimestamp(TimeMark.ago(maxDuration), Int.MAX_VALUE)).clear() + } + + +} diff --git a/src/main/kotlin/features/mining/PickaxeAbility.kt b/src/main/kotlin/features/mining/PickaxeAbility.kt new file mode 100644 index 0000000..7879f2d --- /dev/null +++ b/src/main/kotlin/features/mining/PickaxeAbility.kt @@ -0,0 +1,176 @@ + +package moe.nea.firmament.features.mining + +import java.util.regex.Pattern +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import net.minecraft.item.ItemStack +import net.minecraft.util.DyeColor +import net.minecraft.util.Hand +import net.minecraft.util.Identifier +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.HudRenderEvent +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.events.SlotClickEvent +import moe.nea.firmament.events.WorldReadyEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.DurabilityBarEvent +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SHORT_NUMBER_FORMAT +import moe.nea.firmament.util.TIME_PATTERN +import moe.nea.firmament.util.TimeMark +import moe.nea.firmament.util.extraAttributes +import moe.nea.firmament.util.item.displayNameAccordingToNbt +import moe.nea.firmament.util.item.loreAccordingToNbt +import moe.nea.firmament.util.parseShortNumber +import moe.nea.firmament.util.parseTimePattern +import moe.nea.firmament.util.render.RenderCircleProgress +import moe.nea.firmament.util.render.lerp +import moe.nea.firmament.util.toShedaniel +import moe.nea.firmament.util.unformattedString +import moe.nea.firmament.util.useMatch + +object PickaxeAbility : FirmamentFeature { + override val identifier: String + get() = "pickaxe-info" + + + object TConfig : ManagedConfig(identifier) { + val cooldownEnabled by toggle("ability-cooldown") { true } + val cooldownScale by integer("ability-scale", 16, 64) { 16 } + val drillFuelBar by toggle("fuel-bar") { true } + } + + var lobbyJoinTime = TimeMark.farPast() + var lastUsage = mutableMapOf<String, TimeMark>() + var abilityOverride: String? = null + var defaultAbilityDurations = mutableMapOf<String, Duration>( + "Mining Speed Boost" to 120.seconds, + "Pickobulus" to 110.seconds, + "Gemstone Infusion" to 140.seconds, + "Hazardous Miner" to 140.seconds, + "Maniac Miner" to 59.seconds, + "Vein Seeker" to 60.seconds + ) + + override val config: ManagedConfig + get() = TConfig + + fun getCooldownPercentage(name: String, cooldown: Duration): Double { + val sinceLastUsage = lastUsage[name]?.passedTime() ?: Duration.INFINITE + if (sinceLastUsage < cooldown) + return sinceLastUsage / cooldown + val sinceLobbyJoin = lobbyJoinTime.passedTime() + val halfCooldown = cooldown / 2 + if (sinceLobbyJoin < halfCooldown) { + return (sinceLobbyJoin / halfCooldown) + } + return 1.0 + } + + @Subscribe + fun onSlotClick(it: SlotClickEvent) { + if (MC.screen?.title?.unformattedString == "Heart of the Mountain") { + val name = it.stack.displayNameAccordingToNbt?.unformattedString ?: return + val cooldown = it.stack.loreAccordingToNbt.firstNotNullOfOrNull { + cooldownPattern.useMatch(it.unformattedString) { + parseTimePattern(group("cooldown")) + } + } ?: return + defaultAbilityDurations[name] = cooldown + } + } + + @Subscribe + fun onDurabilityBar(it: DurabilityBarEvent) { + if (!TConfig.drillFuelBar) return + val lore = it.item.loreAccordingToNbt + if (lore.lastOrNull()?.unformattedString?.contains("DRILL") != true) return + val maxFuel = lore.firstNotNullOfOrNull { + fuelPattern.useMatch(it.unformattedString) { + parseShortNumber(group("maxFuel")) + } + } ?: return + val extra = it.item.extraAttributes + if (!extra.contains("drill_fuel")) return + val fuel = extra.getInt("drill_fuel") + val percentage = fuel / maxFuel.toFloat() + it.barOverride = DurabilityBarEvent.DurabilityBar( + lerp( + DyeColor.RED.toShedaniel(), + DyeColor.GREEN.toShedaniel(), + percentage + ), percentage + ) + } + + @Subscribe + fun onChatMessage(it: ProcessChatEvent) { + abilityUsePattern.useMatch(it.unformattedString) { + lastUsage[group("name")] = TimeMark.now() + } + abilitySwitchPattern.useMatch(it.unformattedString) { + abilityOverride = group("ability") + } + } + + @Subscribe + fun onWorldReady(event: WorldReadyEvent) { + lastUsage.clear() + lobbyJoinTime = TimeMark.now() + abilityOverride = null + } + + val abilityUsePattern = Pattern.compile("You used your (?<name>.*) Pickaxe Ability!") + val fuelPattern = Pattern.compile("Fuel: .*/(?<maxFuel>$SHORT_NUMBER_FORMAT)") + + data class PickaxeAbilityData( + val name: String, + val cooldown: Duration, + ) + + fun getCooldownFromLore(itemStack: ItemStack): PickaxeAbilityData? { + val lore = itemStack.loreAccordingToNbt + if (!lore.any { it.unformattedString.contains("Breaking Power") == true }) + return null + val cooldown = lore.firstNotNullOfOrNull { + cooldownPattern.useMatch(it.unformattedString) { + parseTimePattern(group("cooldown")) + } + } ?: return null + val name = lore.firstNotNullOfOrNull { + abilityPattern.useMatch(it.unformattedString) { + group("name") + } + } ?: return null + return PickaxeAbilityData(name, cooldown) + } + + + val cooldownPattern = Pattern.compile("Cooldown: (?<cooldown>$TIME_PATTERN)") + val abilityPattern = Pattern.compile("Ability: (?<name>.*) {2}RIGHT CLICK") + val abilitySwitchPattern = + Pattern.compile("You selected (?<ability>.*) as your Pickaxe Ability\\. This ability will apply to all of your pickaxes!") + + + @Subscribe + fun renderHud(event: HudRenderEvent) { + if (!TConfig.cooldownEnabled) return + var ability = getCooldownFromLore(MC.player?.getStackInHand(Hand.MAIN_HAND) ?: return) ?: return + defaultAbilityDurations[ability.name] = ability.cooldown + val ao = abilityOverride + if (ao != ability.name && ao != null) { + ability = PickaxeAbilityData(ao, defaultAbilityDurations[ao] ?: 120.seconds) + } + event.context.matrices.push() + event.context.matrices.translate(MC.window.scaledWidth / 2F, MC.window.scaledHeight / 2F, 0F) + event.context.matrices.scale(TConfig.cooldownScale.toFloat(), TConfig.cooldownScale.toFloat(), 1F) + RenderCircleProgress.renderCircle( + event.context, Identifier.of("firmament", "textures/gui/circle.png"), + getCooldownPercentage(ability.name, ability.cooldown).toFloat(), + 0f, 1f, 0f, 1f + ) + event.context.matrices.pop() + } +} diff --git a/src/main/kotlin/features/mining/PristineProfitTracker.kt b/src/main/kotlin/features/mining/PristineProfitTracker.kt new file mode 100644 index 0000000..f1bc7e5 --- /dev/null +++ b/src/main/kotlin/features/mining/PristineProfitTracker.kt @@ -0,0 +1,133 @@ + +package moe.nea.firmament.features.mining + +import io.github.notenoughupdates.moulconfig.xml.Bind +import moe.nea.jarvis.api.Point +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import kotlin.time.Duration.Companion.seconds +import net.minecraft.text.Text +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.gui.hud.MoulConfigHud +import moe.nea.firmament.util.BazaarPriceStrategy +import moe.nea.firmament.util.FirmFormatters.formatCommas +import moe.nea.firmament.util.SkyblockId +import moe.nea.firmament.util.data.ProfileSpecificDataHolder +import moe.nea.firmament.util.formattedString +import moe.nea.firmament.util.parseIntWithComma +import moe.nea.firmament.util.useMatch + +object PristineProfitTracker : FirmamentFeature { + override val identifier: String + get() = "pristine-profit" + + enum class GemstoneKind( + val label: String, + val flawedId: SkyblockId, + ) { + SAPPHIRE("Sapphire", SkyblockId("FLAWED_SAPPHIRE_GEM")), + RUBY("Ruby", SkyblockId("FLAWED_RUBY_GEM")), + AMETHYST("Amethyst", SkyblockId("FLAWED_AMETHYST_GEM")), + AMBER("Amber", SkyblockId("FLAWED_AMBER_GEM")), + TOPAZ("Topaz", SkyblockId("FLAWED_TOPAZ_GEM")), + JADE("Jade", SkyblockId("FLAWED_JADE_GEM")), + JASPER("Jasper", SkyblockId("FLAWED_JASPER_GEM")), + OPAL("Opal", SkyblockId("FLAWED_OPAL_GEM")), + } + + @Serializable + data class Data( + var maxMoneyPerSecond: Double = 1.0, + var maxCollectionPerSecond: Double = 1.0, + ) + + object DConfig : ProfileSpecificDataHolder<Data>(serializer(), identifier, ::Data) + + override val config: ManagedConfig? + get() = TConfig + + object TConfig : ManagedConfig(identifier) { + val timeout by duration("timeout", 0.seconds, 120.seconds) { 30.seconds } + val gui by position("position", 80, 30) { Point(0.05, 0.2) } + } + + val sellingStrategy = BazaarPriceStrategy.SELL_ORDER + + val pristineRegex = + "PRISTINE! You found . Flawed (?<kind>${ + GemstoneKind.entries.joinToString("|") { it.label } + }) Gemstone x(?<count>[0-9,]+)!".toPattern() + + val collectionHistogram = Histogram<Double>(10000, 180.seconds) + val moneyHistogram = Histogram<Double>(10000, 180.seconds) + + object ProfitHud : MoulConfigHud("pristine_profit", TConfig.gui) { + @field:Bind + var moneyCurrent: Double = 0.0 + + @field:Bind + var moneyMax: Double = 1.0 + + @field:Bind + var moneyText = "" + + @field:Bind + var collectionCurrent = 0.0 + + @field:Bind + var collectionMax = 1.0 + + @field:Bind + var collectionText = "" + override fun shouldRender(): Boolean = collectionHistogram.latestUpdate().passedTime() < TConfig.timeout + } + + val SECONDS_PER_HOUR = 3600 + val ROUGHS_PER_FLAWED = 80 + + fun updateUi() { + val collectionPerSecond = collectionHistogram.averagePer({ it }, 1.seconds) + val moneyPerSecond = moneyHistogram.averagePer({ it }, 1.seconds) + if (collectionPerSecond == null || moneyPerSecond == null) return + ProfitHud.collectionCurrent = collectionPerSecond + ProfitHud.collectionText = Text.stringifiedTranslatable("firmament.pristine-profit.collection", + formatCommas(collectionPerSecond * SECONDS_PER_HOUR, + 1)).formattedString() + ProfitHud.moneyCurrent = moneyPerSecond + ProfitHud.moneyText = Text.stringifiedTranslatable("firmament.pristine-profit.money", + formatCommas(moneyPerSecond * SECONDS_PER_HOUR, 1)) + .formattedString() + val data = DConfig.data + if (data != null) { + if (data.maxCollectionPerSecond < collectionPerSecond && collectionHistogram.oldestUpdate() + .passedTime() > 30.seconds + ) { + data.maxCollectionPerSecond = collectionPerSecond + DConfig.markDirty() + } + if (data.maxMoneyPerSecond < moneyPerSecond && moneyHistogram.oldestUpdate().passedTime() > 30.seconds) { + data.maxMoneyPerSecond = moneyPerSecond + DConfig.markDirty() + } + ProfitHud.collectionMax = maxOf(data.maxCollectionPerSecond, collectionPerSecond) + ProfitHud.moneyMax = maxOf(data.maxMoneyPerSecond, moneyPerSecond) + } + } + + + @Subscribe + fun onMessage(it: ProcessChatEvent) { + pristineRegex.useMatch(it.unformattedString) { + val gemstoneKind = GemstoneKind.valueOf(group("kind").uppercase()) + val flawedCount = parseIntWithComma(group("count")) + val moneyAmount = sellingStrategy.getSellPrice(gemstoneKind.flawedId) * flawedCount + moneyHistogram.record(moneyAmount) + val collectionAmount = flawedCount * ROUGHS_PER_FLAWED + collectionHistogram.record(collectionAmount.toDouble()) + updateUi() + } + } +} diff --git a/src/main/kotlin/features/notifications/Notifications.kt b/src/main/kotlin/features/notifications/Notifications.kt new file mode 100644 index 0000000..8d912d1 --- /dev/null +++ b/src/main/kotlin/features/notifications/Notifications.kt @@ -0,0 +1,7 @@ + +package moe.nea.firmament.features.notifications + +import moe.nea.firmament.features.FirmamentFeature + +object Notifications { +} diff --git a/src/main/kotlin/features/texturepack/AlwaysPredicate.kt b/src/main/kotlin/features/texturepack/AlwaysPredicate.kt new file mode 100644 index 0000000..4dd28df --- /dev/null +++ b/src/main/kotlin/features/texturepack/AlwaysPredicate.kt @@ -0,0 +1,17 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import net.minecraft.item.ItemStack + +object AlwaysPredicate : FirmamentModelPredicate { + override fun test(stack: ItemStack): Boolean { + return true + } + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate { + return AlwaysPredicate + } + } +} diff --git a/src/main/kotlin/features/texturepack/AndPredicate.kt b/src/main/kotlin/features/texturepack/AndPredicate.kt new file mode 100644 index 0000000..55a4f32 --- /dev/null +++ b/src/main/kotlin/features/texturepack/AndPredicate.kt @@ -0,0 +1,26 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import net.minecraft.item.ItemStack + +class AndPredicate(val children: Array<FirmamentModelPredicate>) : FirmamentModelPredicate { + override fun test(stack: ItemStack): Boolean { + return children.all { it.test(stack) } + } + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate { + val children = + (jsonElement as JsonArray) + .flatMap { + CustomModelOverrideParser.parsePredicates(it as JsonObject) + } + .toTypedArray() + return AndPredicate(children) + } + + } +} diff --git a/src/main/kotlin/features/texturepack/BakedModelExtra.kt b/src/main/kotlin/features/texturepack/BakedModelExtra.kt new file mode 100644 index 0000000..ae1f6d5 --- /dev/null +++ b/src/main/kotlin/features/texturepack/BakedModelExtra.kt @@ -0,0 +1,9 @@ + +package moe.nea.firmament.features.texturepack + +import net.minecraft.client.render.model.BakedModel + +interface BakedModelExtra { + fun getHeadModel_firmament(): BakedModel? + fun setHeadModel_firmament(headModel: BakedModel?) +} diff --git a/src/main/kotlin/features/texturepack/BakedOverrideData.kt b/src/main/kotlin/features/texturepack/BakedOverrideData.kt new file mode 100644 index 0000000..c012883 --- /dev/null +++ b/src/main/kotlin/features/texturepack/BakedOverrideData.kt @@ -0,0 +1,8 @@ + +package moe.nea.firmament.features.texturepack + +interface BakedOverrideData { + fun getFirmamentOverrides(): Array<FirmamentModelPredicate>? + fun setFirmamentOverrides(overrides: Array<FirmamentModelPredicate>?) + +} diff --git a/src/main/kotlin/features/texturepack/CustomBlockTextures.kt b/src/main/kotlin/features/texturepack/CustomBlockTextures.kt new file mode 100644 index 0000000..0f2c2e6 --- /dev/null +++ b/src/main/kotlin/features/texturepack/CustomBlockTextures.kt @@ -0,0 +1,295 @@ +@file:UseSerializers(BlockPosSerializer::class, IdentifierSerializer::class) + +package moe.nea.firmament.features.texturepack + +import java.util.concurrent.CompletableFuture +import net.fabricmc.loader.api.FabricLoader +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.UseSerializers +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 kotlinx.serialization.serializer +import kotlin.jvm.optionals.getOrNull +import net.minecraft.block.Block +import net.minecraft.block.BlockState +import net.minecraft.client.render.model.BakedModel +import net.minecraft.client.util.ModelIdentifier +import net.minecraft.registry.RegistryKey +import net.minecraft.registry.RegistryKeys +import net.minecraft.resource.ResourceManager +import net.minecraft.resource.SinglePreparationResourceReloader +import net.minecraft.util.Identifier +import net.minecraft.util.math.BlockPos +import net.minecraft.util.profiler.Profiler +import moe.nea.firmament.Firmament +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.BakeExtraModelsEvent +import moe.nea.firmament.events.EarlyResourceReloadEvent +import moe.nea.firmament.events.FinalizeResourceManagerEvent +import moe.nea.firmament.events.SkyblockServerUpdateEvent +import moe.nea.firmament.features.texturepack.CustomGlobalTextures.logger +import moe.nea.firmament.util.IdentifierSerializer +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SBData +import moe.nea.firmament.util.SkyBlockIsland +import moe.nea.firmament.util.json.BlockPosSerializer +import moe.nea.firmament.util.json.SingletonSerializableList + + +object CustomBlockTextures { + @Serializable + data class CustomBlockOverride( + val modes: @Serializable(SingletonSerializableList::class) List<String>, + val area: List<Area>? = null, + val replacements: Map<Identifier, Replacement>, + ) + + @Serializable(with = Replacement.Serializer::class) + data class Replacement( + val block: Identifier, + val sound: Identifier?, + ) { + + @Transient + val blockModelIdentifier get() = ModelIdentifier(block.withPrefixedPath("block/"), "firmament") + + @Transient + val bakedModel: BakedModel by lazy(LazyThreadSafetyMode.NONE) { + MC.instance.bakedModelManager.getModel(blockModelIdentifier) + } + + @OptIn(ExperimentalSerializationApi::class) + @kotlinx.serialization.Serializer(Replacement::class) + object DefaultSerializer : KSerializer<Replacement> + + object Serializer : KSerializer<Replacement> { + val delegate = serializer<JsonElement>() + override val descriptor: SerialDescriptor + get() = delegate.descriptor + + override fun deserialize(decoder: Decoder): Replacement { + val jsonElement = decoder.decodeSerializableValue(delegate) + if (jsonElement is JsonPrimitive) { + require(jsonElement.isString) + return Replacement(Identifier.tryParse(jsonElement.content)!!, null) + } + return (decoder as JsonDecoder).json.decodeFromJsonElement(DefaultSerializer, jsonElement) + } + + override fun serialize(encoder: Encoder, value: Replacement) { + encoder.encodeSerializableValue(DefaultSerializer, value) + } + } + } + + @Serializable + data class Area( + val min: BlockPos, + val max: BlockPos, + ) { + @Transient + val realMin = BlockPos( + minOf(min.x, max.x), + minOf(min.y, max.y), + minOf(min.z, max.z), + ) + + @Transient + val realMax = BlockPos( + maxOf(min.x, max.x), + maxOf(min.y, max.y), + maxOf(min.z, max.z), + ) + + fun roughJoin(other: Area): Area { + return Area( + BlockPos( + minOf(realMin.x, other.realMin.x), + minOf(realMin.y, other.realMin.y), + minOf(realMin.z, other.realMin.z), + ), + BlockPos( + maxOf(realMax.x, other.realMax.x), + maxOf(realMax.y, other.realMax.y), + maxOf(realMax.z, other.realMax.z), + ) + ) + } + + fun contains(blockPos: BlockPos): Boolean { + return (blockPos.x in realMin.x..realMax.x) && + (blockPos.y in realMin.y..realMax.y) && + (blockPos.z in realMin.z..realMax.z) + } + } + + data class LocationReplacements( + val lookup: Map<Block, List<BlockReplacement>> + ) + + data class BlockReplacement( + val checks: List<Area>?, + val replacement: Replacement, + ) { + val roughCheck by lazy(LazyThreadSafetyMode.NONE) { + if (checks == null || checks.size < 3) return@lazy null + checks.reduce { acc, next -> acc.roughJoin(next) } + } + } + + data class BakedReplacements(val data: Map<SkyBlockIsland, LocationReplacements>) + + var allLocationReplacements: BakedReplacements = BakedReplacements(mapOf()) + var currentIslandReplacements: LocationReplacements? = null + + fun refreshReplacements() { + val location = SBData.skyblockLocation + val replacements = + if (CustomSkyBlockTextures.TConfig.enableBlockOverrides) location?.let(allLocationReplacements.data::get) + else null + val lastReplacements = currentIslandReplacements + currentIslandReplacements = replacements + if (lastReplacements != replacements) { + MC.nextTick { + MC.worldRenderer.chunks?.chunks?.forEach { + // false schedules rebuilds outside a 27 block radius to happen async + it.scheduleRebuild(false) + } + sodiumReloadTask?.run() + } + } + } + + private val sodiumReloadTask = runCatching { + Class.forName("moe.nea.firmament.compat.sodium.SodiumChunkReloader").getConstructor().newInstance() as Runnable + }.getOrElse { + if (FabricLoader.getInstance().isModLoaded("sodium")) + logger.error("Could not create sodium chunk reloader") + null + } + + + fun matchesPosition(replacement: BlockReplacement, blockPos: BlockPos?): Boolean { + if (blockPos == null) return true + val rc = replacement.roughCheck + if (rc != null && !rc.contains(blockPos)) return false + val areas = replacement.checks + if (areas != null && !areas.any { it.contains(blockPos) }) return false + return true + } + + @JvmStatic + fun getReplacementModel(block: BlockState, blockPos: BlockPos?): BakedModel? { + return getReplacement(block, blockPos)?.bakedModel + } + + @JvmStatic + fun getReplacement(block: BlockState, blockPos: BlockPos?): Replacement? { + if (isInFallback() && blockPos == null) return null + val replacements = currentIslandReplacements?.lookup?.get(block.block) ?: return null + for (replacement in replacements) { + if (replacement.checks == null || matchesPosition(replacement, blockPos)) + return replacement.replacement + } + return null + } + + + @Subscribe + fun onLocation(event: SkyblockServerUpdateEvent) { + refreshReplacements() + } + + @Volatile + var preparationFuture: CompletableFuture<BakedReplacements> = CompletableFuture.completedFuture(BakedReplacements( + mapOf())) + + val insideFallbackCall = ThreadLocal.withInitial { 0 } + + @JvmStatic + fun enterFallbackCall() { + insideFallbackCall.set(insideFallbackCall.get() + 1) + } + + fun isInFallback() = insideFallbackCall.get() > 0 + + @JvmStatic + fun exitFallbackCall() { + insideFallbackCall.set(insideFallbackCall.get() - 1) + } + + @Subscribe + fun onEarlyReload(event: EarlyResourceReloadEvent) { + preparationFuture = CompletableFuture + .supplyAsync( + { prepare(event.resourceManager) }, event.preparationExecutor) + } + + @Subscribe + fun bakeExtraModels(event: BakeExtraModelsEvent) { + preparationFuture.join().data.values + .flatMap { it.lookup.values } + .flatten() + .mapTo(mutableSetOf()) { it.replacement.blockModelIdentifier } + .forEach { event.addNonItemModel(it) } + } + + private fun prepare(manager: ResourceManager): BakedReplacements { + val resources = manager.findResources("overrides/blocks") { + it.namespace == "firmskyblock" && it.path.endsWith(".json") + } + val map = mutableMapOf<SkyBlockIsland, MutableMap<Block, MutableList<BlockReplacement>>>() + for ((file, resource) in resources) { + val json = + Firmament.tryDecodeJsonFromStream<CustomBlockOverride>(resource.inputStream) + .getOrElse { ex -> + logger.error("Failed to load block texture override at $file", ex) + continue + } + for (mode in json.modes) { + val island = SkyBlockIsland.forMode(mode) + val islandMpa = map.getOrPut(island, ::mutableMapOf) + for ((blockId, replacement) in json.replacements) { + val block = MC.defaultRegistries.getWrapperOrThrow(RegistryKeys.BLOCK) + .getOptional(RegistryKey.of(RegistryKeys.BLOCK, blockId)) + .getOrNull() + if (block == null) { + logger.error("Failed to load block texture override at ${file}: unknown block '$blockId'") + continue + } + val replacements = islandMpa.getOrPut(block.value(), ::mutableListOf) + replacements.add(BlockReplacement(json.area, replacement)) + } + } + } + + return BakedReplacements(map.mapValues { LocationReplacements(it.value) }) + } + + @JvmStatic + fun patchIndigo(orig: BakedModel, pos: BlockPos, state: BlockState): BakedModel { + return getReplacementModel(state, pos) ?: orig + } + + @Subscribe + fun onStart(event: FinalizeResourceManagerEvent) { + event.resourceManager.registerReloader(object : + SinglePreparationResourceReloader<BakedReplacements>() { + override fun prepare(manager: ResourceManager, profiler: Profiler): BakedReplacements { + return preparationFuture.join() + } + + override fun apply(prepared: BakedReplacements, manager: ResourceManager, profiler: Profiler?) { + allLocationReplacements = prepared + refreshReplacements() + } + }) + } +} diff --git a/src/main/kotlin/features/texturepack/CustomGlobalArmorOverrides.kt b/src/main/kotlin/features/texturepack/CustomGlobalArmorOverrides.kt new file mode 100644 index 0000000..23577ee --- /dev/null +++ b/src/main/kotlin/features/texturepack/CustomGlobalArmorOverrides.kt @@ -0,0 +1,106 @@ + +@file:UseSerializers(IdentifierSerializer::class) + +package moe.nea.firmament.features.texturepack + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.UseSerializers +import net.minecraft.item.ArmorMaterial +import net.minecraft.item.ItemStack +import net.minecraft.resource.ResourceManager +import net.minecraft.resource.SinglePreparationResourceReloader +import net.minecraft.util.Identifier +import net.minecraft.util.profiler.Profiler +import moe.nea.firmament.Firmament +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.FinalizeResourceManagerEvent +import moe.nea.firmament.events.subscription.SubscriptionOwner +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.features.texturepack.CustomGlobalTextures.logger +import moe.nea.firmament.util.IdentifierSerializer +import moe.nea.firmament.util.IdentityCharacteristics +import moe.nea.firmament.util.computeNullableFunction +import moe.nea.firmament.util.skyBlockId + +object CustomGlobalArmorOverrides : SubscriptionOwner { + @Serializable + data class ArmorOverride( + @SerialName("item_ids") + val itemIds: List<String>, + val layers: List<ArmorOverrideLayer>, + val overrides: List<ArmorOverrideOverride> = listOf(), + ) { + @Transient + val bakedLayers = bakeLayers(layers) + } + + fun bakeLayers(layers: List<ArmorOverrideLayer>): List<ArmorMaterial.Layer> { + return layers.map { ArmorMaterial.Layer(it.identifier, it.suffix, it.tint) } + } + + @Serializable + data class ArmorOverrideLayer( + val tint: Boolean = false, + val identifier: Identifier, + val suffix: String = "", + ) + + @Serializable + data class ArmorOverrideOverride( + val predicate: FirmamentModelPredicate, + val layers: List<ArmorOverrideLayer>, + ) { + @Transient + val bakedLayers = bakeLayers(layers) + } + + override val delegateFeature: FirmamentFeature + get() = CustomSkyBlockTextures + + val overrideCache = mutableMapOf<IdentityCharacteristics<ItemStack>, Any>() + + @JvmStatic + fun overrideArmor(stack: ItemStack): List<ArmorMaterial.Layer>? { + if (!CustomSkyBlockTextures.TConfig.enableArmorOverrides) return null + return overrideCache.computeNullableFunction(IdentityCharacteristics(stack)) { + val id = stack.skyBlockId ?: return@computeNullableFunction null + val override = overrides[id.neuItem] ?: return@computeNullableFunction null + for (suboverride in override.overrides) { + if (suboverride.predicate.test(stack)) { + return@computeNullableFunction suboverride.bakedLayers + } + } + return@computeNullableFunction override.bakedLayers + } + } + + var overrides: Map<String, ArmorOverride> = mapOf() + + @Subscribe + fun onStart(event: FinalizeResourceManagerEvent) { + event.resourceManager.registerReloader(object : + SinglePreparationResourceReloader<Map<String, ArmorOverride>>() { + override fun prepare(manager: ResourceManager, profiler: Profiler): Map<String, ArmorOverride> { + val overrideFiles = manager.findResources("overrides/armor_models") { + it.namespace == "firmskyblock" && it.path.endsWith(".json") + } + val overrides = overrideFiles.mapNotNull { + Firmament.tryDecodeJsonFromStream<ArmorOverride>(it.value.inputStream).getOrElse { ex -> + logger.error("Failed to load armor texture override at ${it.key}", ex) + null + } + } + val associatedMap = overrides.flatMap { obj -> obj.itemIds.map { it to obj } } + .toMap() + return associatedMap + } + + override fun apply(prepared: Map<String, ArmorOverride>, manager: ResourceManager, profiler: Profiler) { + overrides = prepared + } + }) + } + +} diff --git a/src/main/kotlin/features/texturepack/CustomGlobalTextures.kt b/src/main/kotlin/features/texturepack/CustomGlobalTextures.kt new file mode 100644 index 0000000..d64c844 --- /dev/null +++ b/src/main/kotlin/features/texturepack/CustomGlobalTextures.kt @@ -0,0 +1,167 @@ + +@file:UseSerializers(IdentifierSerializer::class, CustomModelOverrideParser.FirmamentRootPredicateSerializer::class) + +package moe.nea.firmament.features.texturepack + + +import java.util.concurrent.CompletableFuture +import org.slf4j.LoggerFactory +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlin.jvm.optionals.getOrNull +import net.minecraft.client.render.item.ItemModels +import net.minecraft.client.render.model.BakedModel +import net.minecraft.client.util.ModelIdentifier +import net.minecraft.item.ItemStack +import net.minecraft.resource.ResourceManager +import net.minecraft.resource.SinglePreparationResourceReloader +import net.minecraft.text.Text +import net.minecraft.util.Identifier +import net.minecraft.util.profiler.Profiler +import moe.nea.firmament.Firmament +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.BakeExtraModelsEvent +import moe.nea.firmament.events.EarlyResourceReloadEvent +import moe.nea.firmament.events.FinalizeResourceManagerEvent +import moe.nea.firmament.events.ScreenChangeEvent +import moe.nea.firmament.events.subscription.SubscriptionOwner +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.util.IdentifierSerializer +import moe.nea.firmament.util.IdentityCharacteristics +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.computeNullableFunction +import moe.nea.firmament.util.json.SingletonSerializableList +import moe.nea.firmament.util.runNull + +object CustomGlobalTextures : SinglePreparationResourceReloader<CustomGlobalTextures.CustomGuiTextureOverride>(), + SubscriptionOwner { + override val delegateFeature: FirmamentFeature + get() = CustomSkyBlockTextures + + class CustomGuiTextureOverride( + val classes: List<ItemOverrideCollection> + ) + + @Serializable + data class GlobalItemOverride( + val screen: @Serializable(SingletonSerializableList::class) List<Identifier>, + val model: Identifier, + val predicate: FirmamentModelPredicate, + ) + + @Serializable + data class ScreenFilter( + val title: StringMatcher, + ) + + data class ItemOverrideCollection( + val screenFilter: ScreenFilter, + val overrides: List<GlobalItemOverride>, + ) + + @Subscribe + fun onStart(event: FinalizeResourceManagerEvent) { + MC.resourceManager.registerReloader(this) + } + + @Subscribe + fun onEarlyReload(event: EarlyResourceReloadEvent) { + preparationFuture = CompletableFuture + .supplyAsync( + { + prepare(event.resourceManager) + }, event.preparationExecutor) + } + + @Subscribe + fun onBakeModels(event: BakeExtraModelsEvent) { + for (guiClassOverride in preparationFuture.join().classes) { + for (override in guiClassOverride.overrides) { + event.addItemModel(ModelIdentifier(override.model, "inventory")) + } + } + } + + @Volatile + var preparationFuture: CompletableFuture<CustomGuiTextureOverride> = CompletableFuture.completedFuture( + CustomGuiTextureOverride(listOf())) + + override fun prepare(manager: ResourceManager?, profiler: Profiler?): CustomGuiTextureOverride { + return preparationFuture.join() + } + + override fun apply(prepared: CustomGuiTextureOverride, manager: ResourceManager?, profiler: Profiler?) { + this.guiClassOverrides = prepared + } + + val logger = LoggerFactory.getLogger(CustomGlobalTextures::class.java) + fun prepare(manager: ResourceManager): CustomGuiTextureOverride { + val overrideResources = + manager.findResources("overrides/item") { it.namespace == "firmskyblock" && it.path.endsWith(".json") } + .mapNotNull { + Firmament.tryDecodeJsonFromStream<GlobalItemOverride>(it.value.inputStream).getOrElse { ex -> + logger.error("Failed to load global item override at ${it.key}", ex) + null + } + } + + val byGuiClass = overrideResources.flatMap { override -> override.screen.toSet().map { it to override } } + .groupBy { it.first } + val guiClasses = byGuiClass.entries + .mapNotNull { + val key = it.key + val guiClassResource = + manager.getResource(Identifier.of(key.namespace, "filters/screen/${key.path}.json")) + .getOrNull() + ?: return@mapNotNull runNull { + logger.error("Failed to locate screen filter at $key") + } + val screenFilter = + Firmament.tryDecodeJsonFromStream<ScreenFilter>(guiClassResource.inputStream) + .getOrElse { ex -> + logger.error("Failed to load screen filter at $key", ex) + return@mapNotNull null + } + ItemOverrideCollection(screenFilter, it.value.map { it.second }) + } + logger.info("Loaded ${overrideResources.size} global item overrides") + return CustomGuiTextureOverride(guiClasses) + } + + var guiClassOverrides = CustomGuiTextureOverride(listOf()) + + var matchingOverrides: Set<ItemOverrideCollection> = setOf() + + @Subscribe + fun onOpenGui(event: ScreenChangeEvent) { + val newTitle = event.new?.title ?: Text.empty() + matchingOverrides = guiClassOverrides.classes + .filterTo(mutableSetOf()) { it.screenFilter.title.matches(newTitle) } + } + + val overrideCache = mutableMapOf<IdentityCharacteristics<ItemStack>, Any>() + + @JvmStatic + fun replaceGlobalModel( + models: ItemModels, + stack: ItemStack, + cir: CallbackInfoReturnable<BakedModel> + ) { + val value = overrideCache.computeNullableFunction(IdentityCharacteristics(stack)) { + for (guiClassOverride in matchingOverrides) { + for (override in guiClassOverride.overrides) { + if (override.predicate.test(stack)) { + return@computeNullableFunction models.modelManager.getModel( + ModelIdentifier(override.model, "inventory")) + } + } + } + null + } + if (value != null) + cir.returnValue = value + } + + +} diff --git a/src/main/kotlin/features/texturepack/CustomModelOverrideParser.kt b/src/main/kotlin/features/texturepack/CustomModelOverrideParser.kt new file mode 100644 index 0000000..a4e7c02 --- /dev/null +++ b/src/main/kotlin/features/texturepack/CustomModelOverrideParser.kt @@ -0,0 +1,74 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonObject +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.minecraft.item.ItemStack +import net.minecraft.util.Identifier + +object CustomModelOverrideParser { + object FirmamentRootPredicateSerializer : KSerializer<FirmamentModelPredicate> { + val delegateSerializer = kotlinx.serialization.json.JsonObject.serializer() + override val descriptor: SerialDescriptor + get() = SerialDescriptor("FirmamentModelRootPredicate", delegateSerializer.descriptor) + + override fun deserialize(decoder: Decoder): FirmamentModelPredicate { + val json = decoder.decodeSerializableValue(delegateSerializer).intoGson() as JsonObject + return AndPredicate(parsePredicates(json).toTypedArray()) + } + + override fun serialize(encoder: Encoder, value: FirmamentModelPredicate) { + TODO("Cannot serialize firmament predicates") + } + } + + val predicateParsers = mutableMapOf<Identifier, FirmamentModelPredicateParser>() + + + fun registerPredicateParser(name: String, parser: FirmamentModelPredicateParser) { + predicateParsers[Identifier.of("firmament", name)] = parser + } + + init { + registerPredicateParser("display_name", DisplayNamePredicate.Parser) + registerPredicateParser("lore", LorePredicate.Parser) + registerPredicateParser("all", AndPredicate.Parser) + registerPredicateParser("any", OrPredicate.Parser) + registerPredicateParser("not", NotPredicate.Parser) + registerPredicateParser("item", ItemPredicate.Parser) + registerPredicateParser("extra_attributes", ExtraAttributesPredicate.Parser) + registerPredicateParser("pet", PetPredicate.Parser) + } + + private val neverPredicate = listOf( + object : FirmamentModelPredicate { + override fun test(stack: ItemStack): Boolean { + return false + } + } + ) + + fun parsePredicates(predicates: JsonObject): List<FirmamentModelPredicate> { + val parsedPredicates = mutableListOf<FirmamentModelPredicate>() + for (predicateName in predicates.keySet()) { + if (!predicateName.startsWith("firmament:")) continue + val identifier = Identifier.of(predicateName) + val parser = predicateParsers[identifier] ?: return neverPredicate + val parsedPredicate = parser.parse(predicates[predicateName]) ?: return neverPredicate + parsedPredicates.add(parsedPredicate) + } + return parsedPredicates + } + + @JvmStatic + fun parseCustomModelOverrides(jsonObject: JsonObject): Array<FirmamentModelPredicate>? { + val predicates = (jsonObject["predicate"] as? JsonObject) ?: return null + val parsedPredicates = parsePredicates(predicates) + if (parsedPredicates.isEmpty()) + return null + return parsedPredicates.toTypedArray() + } +} diff --git a/src/main/kotlin/features/texturepack/CustomSkyBlockTextures.kt b/src/main/kotlin/features/texturepack/CustomSkyBlockTextures.kt new file mode 100644 index 0000000..dec6046 --- /dev/null +++ b/src/main/kotlin/features/texturepack/CustomSkyBlockTextures.kt @@ -0,0 +1,114 @@ +package moe.nea.firmament.features.texturepack + +import com.mojang.authlib.minecraft.MinecraftProfileTexture +import com.mojang.authlib.properties.Property +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable +import net.minecraft.block.SkullBlock +import net.minecraft.client.MinecraftClient +import net.minecraft.client.render.RenderLayer +import net.minecraft.client.util.ModelIdentifier +import net.minecraft.component.type.ProfileComponent +import net.minecraft.util.Identifier +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.BakeExtraModelsEvent +import moe.nea.firmament.events.CustomItemModelEvent +import moe.nea.firmament.events.TickEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.IdentityCharacteristics +import moe.nea.firmament.util.item.decodeProfileTextureProperty +import moe.nea.firmament.util.skyBlockId + +object CustomSkyBlockTextures : FirmamentFeature { + override val identifier: String + get() = "custom-skyblock-textures" + + object TConfig : ManagedConfig(identifier) { + val enabled by toggle("enabled") { true } + val skullsEnabled by toggle("skulls-enabled") { true } + val cacheDuration by integer("cache-duration", 0, 20) { 1 } + val enableModelOverrides by toggle("model-overrides") { true } + val enableArmorOverrides by toggle("armor-overrides") { true } + val enableBlockOverrides by toggle("block-overrides") { true } + } + + override val config: ManagedConfig + get() = TConfig + + @Subscribe + fun onTick(it: TickEvent) { + if (TConfig.cacheDuration < 1 || it.tickCount % TConfig.cacheDuration == 0) { + // TODO: unify all of those caches somehow + CustomItemModelEvent.clearCache() + skullTextureCache.clear() + CustomGlobalTextures.overrideCache.clear() + CustomGlobalArmorOverrides.overrideCache.clear() + } + } + + @Subscribe + fun bakeCustomFirmModels(event: BakeExtraModelsEvent) { + val resources = + MinecraftClient.getInstance().resourceManager.findResources("models/item" + ) { it: Identifier -> + "firmskyblock" == it.namespace && it.path + .endsWith(".json") + } + for (identifier in resources.keys) { + val modelId = ModelIdentifier.ofInventoryVariant( + Identifier.of( + "firmskyblock", + identifier.path.substring( + "models/item/".length, + identifier.path.length - ".json".length), + )) + event.addItemModel(modelId) + } + } + + @Subscribe + fun onCustomModelId(it: CustomItemModelEvent) { + if (!TConfig.enabled) return + val id = it.itemStack.skyBlockId ?: return + it.overrideModel = ModelIdentifier.ofInventoryVariant(Identifier.of("firmskyblock", id.identifier.path)) + } + + private val skullTextureCache = mutableMapOf<IdentityCharacteristics<ProfileComponent>, Any>() + private val sentinelPresentInvalid = Object() + + private val mcUrlRegex = "https?://textures.minecraft.net/texture/([a-fA-F0-9]+)".toRegex() + + fun getSkullId(textureProperty: Property): String? { + val texture = decodeProfileTextureProperty(textureProperty) ?: return null + val textureUrl = + texture.textures[MinecraftProfileTexture.Type.SKIN]?.url ?: return null + val mcUrlData = mcUrlRegex.matchEntire(textureUrl) ?: return null + return mcUrlData.groupValues[1] + } + + fun getSkullTexture(profile: ProfileComponent): Identifier? { + val id = getSkullId(profile.properties["textures"].firstOrNull() ?: return null) ?: return null + return Identifier.of("firmskyblock", "textures/placedskull/$id.png") + } + + fun modifySkullTexture( + type: SkullBlock.SkullType?, + component: ProfileComponent?, + cir: CallbackInfoReturnable<RenderLayer> + ) { + if (type != SkullBlock.Type.PLAYER) return + if (!TConfig.skullsEnabled) return + if (component == null) return + val ic = IdentityCharacteristics(component) + + val n = skullTextureCache.getOrPut(ic) { + val id = getSkullTexture(component) ?: return@getOrPut sentinelPresentInvalid + if (!MinecraftClient.getInstance().resourceManager.getResource(id).isPresent) { + return@getOrPut sentinelPresentInvalid + } + return@getOrPut id + } + if (n === sentinelPresentInvalid) return + cir.returnValue = RenderLayer.getEntityTranslucent(n as Identifier) + } +} diff --git a/src/main/kotlin/features/texturepack/DisplayNamePredicate.kt b/src/main/kotlin/features/texturepack/DisplayNamePredicate.kt new file mode 100644 index 0000000..c89931e --- /dev/null +++ b/src/main/kotlin/features/texturepack/DisplayNamePredicate.kt @@ -0,0 +1,22 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import net.minecraft.item.ItemStack +import net.minecraft.nbt.NbtElement +import net.minecraft.nbt.NbtString +import moe.nea.firmament.util.item.displayNameAccordingToNbt +import moe.nea.firmament.util.item.loreAccordingToNbt + +data class DisplayNamePredicate(val stringMatcher: StringMatcher) : FirmamentModelPredicate { + override fun test(stack: ItemStack): Boolean { + val display = stack.displayNameAccordingToNbt + return stringMatcher.matches(display) + } + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate { + return DisplayNamePredicate(StringMatcher.parse(jsonElement)) + } + } +} diff --git a/src/main/kotlin/features/texturepack/ExtraAttributesPredicate.kt b/src/main/kotlin/features/texturepack/ExtraAttributesPredicate.kt new file mode 100644 index 0000000..4114f45 --- /dev/null +++ b/src/main/kotlin/features/texturepack/ExtraAttributesPredicate.kt @@ -0,0 +1,268 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import net.minecraft.item.ItemStack +import net.minecraft.nbt.NbtByte +import net.minecraft.nbt.NbtCompound +import net.minecraft.nbt.NbtDouble +import net.minecraft.nbt.NbtElement +import net.minecraft.nbt.NbtFloat +import net.minecraft.nbt.NbtInt +import net.minecraft.nbt.NbtList +import net.minecraft.nbt.NbtLong +import net.minecraft.nbt.NbtShort +import net.minecraft.nbt.NbtString +import moe.nea.firmament.util.extraAttributes + +fun interface NbtMatcher { + fun matches(nbt: NbtElement): Boolean + + object Parser { + fun parse(jsonElement: JsonElement): NbtMatcher? { + if (jsonElement is JsonPrimitive) { + if (jsonElement.isString) { + val string = jsonElement.asString + return MatchStringExact(string) + } + if (jsonElement.isNumber) { + return MatchNumberExact(jsonElement.asLong) //TODO: parse generic number + } + } + if (jsonElement is JsonObject) { + var encounteredParser: NbtMatcher? = null + for (entry in ExclusiveParserType.entries) { + val data = jsonElement[entry.key] ?: continue + if (encounteredParser != null) { + // TODO: warn + return null + } + encounteredParser = entry.parse(data) ?: return null + } + return encounteredParser + } + return null + } + + enum class ExclusiveParserType(val key: String) { + STRING("string") { + override fun parse(element: JsonElement): NbtMatcher? { + return MatchString(StringMatcher.parse(element)) + } + }, + INT("int") { + override fun parse(element: JsonElement): NbtMatcher? { + return parseGenericNumber(element, + { it.asInt }, + { (it as? NbtInt)?.intValue() }, + { a, b -> + if (a == b) Comparison.EQUAL + else if (a < b) Comparison.LESS_THAN + else Comparison.GREATER + }) + } + }, + FLOAT("float") { + override fun parse(element: JsonElement): NbtMatcher? { + return parseGenericNumber(element, + { it.asFloat }, + { (it as? NbtFloat)?.floatValue() }, + { a, b -> + if (a == b) Comparison.EQUAL + else if (a < b) Comparison.LESS_THAN + else Comparison.GREATER + }) + } + }, + DOUBLE("double") { + override fun parse(element: JsonElement): NbtMatcher? { + return parseGenericNumber(element, + { it.asDouble }, + { (it as? NbtDouble)?.doubleValue() }, + { a, b -> + if (a == b) Comparison.EQUAL + else if (a < b) Comparison.LESS_THAN + else Comparison.GREATER + }) + } + }, + LONG("long") { + override fun parse(element: JsonElement): NbtMatcher? { + return parseGenericNumber(element, + { it.asLong }, + { (it as? NbtLong)?.longValue() }, + { a, b -> + if (a == b) Comparison.EQUAL + else if (a < b) Comparison.LESS_THAN + else Comparison.GREATER + }) + } + }, + SHORT("short") { + override fun parse(element: JsonElement): NbtMatcher? { + return parseGenericNumber(element, + { it.asShort }, + { (it as? NbtShort)?.shortValue() }, + { a, b -> + if (a == b) Comparison.EQUAL + else if (a < b) Comparison.LESS_THAN + else Comparison.GREATER + }) + } + }, + BYTE("byte") { + override fun parse(element: JsonElement): NbtMatcher? { + return parseGenericNumber(element, + { it.asByte }, + { (it as? NbtByte)?.byteValue() }, + { a, b -> + if (a == b) Comparison.EQUAL + else if (a < b) Comparison.LESS_THAN + else Comparison.GREATER + }) + } + }, + ; + + abstract fun parse(element: JsonElement): NbtMatcher? + } + + enum class Comparison { + LESS_THAN, EQUAL, GREATER + } + + inline fun <T : Any> parseGenericNumber( + jsonElement: JsonElement, + primitiveExtractor: (JsonPrimitive) -> T?, + crossinline nbtExtractor: (NbtElement) -> T?, + crossinline compare: (T, T) -> Comparison + ): NbtMatcher? { + if (jsonElement is JsonPrimitive) { + val expected = primitiveExtractor(jsonElement) ?: return null + return NbtMatcher { + val actual = nbtExtractor(it) ?: return@NbtMatcher false + compare(actual, expected) == Comparison.EQUAL + } + } + if (jsonElement is JsonObject) { + val minElement = jsonElement.getAsJsonPrimitive("min") + val min = if (minElement != null) primitiveExtractor(minElement) ?: return null else null + val minExclusive = jsonElement.get("minExclusive")?.asBoolean ?: false + val maxElement = jsonElement.getAsJsonPrimitive("max") + val max = if (maxElement != null) primitiveExtractor(maxElement) ?: return null else null + val maxExclusive = jsonElement.get("maxExclusive")?.asBoolean ?: true + if (min == null && max == null) return null + return NbtMatcher { + val actual = nbtExtractor(it) ?: return@NbtMatcher false + if (max != null) { + val comp = compare(actual, max) + if (comp == Comparison.GREATER) return@NbtMatcher false + if (comp == Comparison.EQUAL && maxExclusive) return@NbtMatcher false + } + if (min != null) { + val comp = compare(actual, min) + if (comp == Comparison.LESS_THAN) return@NbtMatcher false + if (comp == Comparison.EQUAL && minExclusive) return@NbtMatcher false + } + return@NbtMatcher true + } + } + return null + + } + } + + class MatchNumberExact(val number: Long) : NbtMatcher { + override fun matches(nbt: NbtElement): Boolean { + return when (nbt) { + is NbtByte -> nbt.byteValue().toLong() == number + is NbtInt -> nbt.intValue().toLong() == number + is NbtShort -> nbt.shortValue().toLong() == number + is NbtLong -> nbt.longValue().toLong() == number + else -> false + } + } + + } + + class MatchStringExact(val string: String) : NbtMatcher { + override fun matches(nbt: NbtElement): Boolean { + return nbt is NbtString && nbt.asString() == string + } + + override fun toString(): String { + return "MatchNbtStringExactly($string)" + } + } + + class MatchString(val string: StringMatcher) : NbtMatcher { + override fun matches(nbt: NbtElement): Boolean { + return nbt is NbtString && string.matches(nbt.asString()) + } + + override fun toString(): String { + return "MatchNbtString($string)" + } + } +} + +data class ExtraAttributesPredicate( + val path: NbtPrism, + val matcher: NbtMatcher, +) : FirmamentModelPredicate { + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate? { + if (jsonElement !is JsonObject) return null + val path = jsonElement.get("path") ?: return null + val pathSegments = if (path is JsonArray) { + path.map { (it as JsonPrimitive).asString } + } else if (path is JsonPrimitive && path.isString) { + path.asString.split(".") + } else return null + val matcher = NbtMatcher.Parser.parse(jsonElement.get("match") ?: jsonElement) + ?: return null + return ExtraAttributesPredicate(NbtPrism(pathSegments), matcher) + } + } + + override fun test(stack: ItemStack): Boolean { + return path.access(stack.extraAttributes) + .any { matcher.matches(it) } + } +} + +class NbtPrism(val path: List<String>) { + override fun toString(): String { + return "Prism($path)" + } + fun access(root: NbtElement): Collection<NbtElement> { + var rootSet = mutableListOf(root) + var switch = mutableListOf<NbtElement>() + for (pathSegment in path) { + if (pathSegment == ".") continue + for (element in rootSet) { + if (element is NbtList) { + if (pathSegment == "*") + switch.addAll(element) + val index = pathSegment.toIntOrNull() ?: continue + if (index !in element.indices) continue + switch.add(element[index]) + } + if (element is NbtCompound) { + if (pathSegment == "*") + element.keys.mapTo(switch) { element.get(it)!! } + switch.add(element.get(pathSegment) ?: continue) + } + } + val temp = switch + switch = rootSet + rootSet = temp + switch.clear() + } + return rootSet + } +} diff --git a/src/main/kotlin/features/texturepack/FirmamentModelPredicate.kt b/src/main/kotlin/features/texturepack/FirmamentModelPredicate.kt new file mode 100644 index 0000000..d11fec0 --- /dev/null +++ b/src/main/kotlin/features/texturepack/FirmamentModelPredicate.kt @@ -0,0 +1,8 @@ + +package moe.nea.firmament.features.texturepack + +import net.minecraft.item.ItemStack + +interface FirmamentModelPredicate { + fun test(stack: ItemStack): Boolean +} diff --git a/src/main/kotlin/features/texturepack/FirmamentModelPredicateParser.kt b/src/main/kotlin/features/texturepack/FirmamentModelPredicateParser.kt new file mode 100644 index 0000000..3ed0c67 --- /dev/null +++ b/src/main/kotlin/features/texturepack/FirmamentModelPredicateParser.kt @@ -0,0 +1,8 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement + +interface FirmamentModelPredicateParser { + fun parse(jsonElement: JsonElement): FirmamentModelPredicate? +} diff --git a/src/main/kotlin/features/texturepack/ItemPredicate.kt b/src/main/kotlin/features/texturepack/ItemPredicate.kt new file mode 100644 index 0000000..4302b53 --- /dev/null +++ b/src/main/kotlin/features/texturepack/ItemPredicate.kt @@ -0,0 +1,32 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive +import kotlin.jvm.optionals.getOrNull +import net.minecraft.item.Item +import net.minecraft.item.ItemStack +import net.minecraft.registry.RegistryKey +import net.minecraft.registry.RegistryKeys +import net.minecraft.util.Identifier +import moe.nea.firmament.util.MC + +class ItemPredicate( + val item: Item +) : FirmamentModelPredicate { + override fun test(stack: ItemStack): Boolean { + return stack.item == item + } + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): ItemPredicate? { + if (jsonElement is JsonPrimitive && jsonElement.isString) { + val itemKey = RegistryKey.of(RegistryKeys.ITEM, + Identifier.tryParse(jsonElement.asString) + ?: return null) + return ItemPredicate(MC.defaultItems.getOptional(itemKey).getOrNull()?.value() ?: return null) + } + return null + } + } +} diff --git a/src/main/kotlin/features/texturepack/JsonUnbakedModelFirmExtra.kt b/src/main/kotlin/features/texturepack/JsonUnbakedModelFirmExtra.kt new file mode 100644 index 0000000..ab9e27d --- /dev/null +++ b/src/main/kotlin/features/texturepack/JsonUnbakedModelFirmExtra.kt @@ -0,0 +1,10 @@ + +package moe.nea.firmament.features.texturepack + +import net.minecraft.util.Identifier + +interface JsonUnbakedModelFirmExtra { + + fun setHeadModel_firmament(identifier: Identifier?) + fun getHeadModel_firmament(): Identifier? +} diff --git a/src/main/kotlin/features/texturepack/LorePredicate.kt b/src/main/kotlin/features/texturepack/LorePredicate.kt new file mode 100644 index 0000000..13e3974 --- /dev/null +++ b/src/main/kotlin/features/texturepack/LorePredicate.kt @@ -0,0 +1,19 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import net.minecraft.item.ItemStack +import moe.nea.firmament.util.item.loreAccordingToNbt + +class LorePredicate(val matcher: StringMatcher) : FirmamentModelPredicate { + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate { + return LorePredicate(StringMatcher.parse(jsonElement)) + } + } + + override fun test(stack: ItemStack): Boolean { + val lore = stack.loreAccordingToNbt + return lore.any { matcher.matches(it) } + } +} diff --git a/src/main/kotlin/features/texturepack/ModelOverrideData.kt b/src/main/kotlin/features/texturepack/ModelOverrideData.kt new file mode 100644 index 0000000..1585bd7 --- /dev/null +++ b/src/main/kotlin/features/texturepack/ModelOverrideData.kt @@ -0,0 +1,7 @@ + +package moe.nea.firmament.features.texturepack + +interface ModelOverrideData { + fun getFirmamentOverrides(): Array<FirmamentModelPredicate>? + fun setFirmamentOverrides(overrides: Array<FirmamentModelPredicate>?) +} diff --git a/src/main/kotlin/features/texturepack/ModelOverrideFilterSet.kt b/src/main/kotlin/features/texturepack/ModelOverrideFilterSet.kt new file mode 100644 index 0000000..4ef8d06 --- /dev/null +++ b/src/main/kotlin/features/texturepack/ModelOverrideFilterSet.kt @@ -0,0 +1,19 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import moe.nea.firmament.util.filter.IteratorFilterSet + +class ModelOverrideFilterSet(original: java.util.Set<Map.Entry<String, JsonElement>>) : + IteratorFilterSet<Map.Entry<String, JsonElement>>(original) { + companion object { + @JvmStatic + fun createFilterSet(set: java.util.Set<*>): java.util.Set<*> { + return ModelOverrideFilterSet(set as java.util.Set<Map.Entry<String, JsonElement>>) as java.util.Set<*> + } + } + + override fun shouldKeepElement(element: Map.Entry<String, JsonElement>): Boolean { + return !element.key.startsWith("firmament:") + } +} diff --git a/src/main/kotlin/features/texturepack/NotPredicate.kt b/src/main/kotlin/features/texturepack/NotPredicate.kt new file mode 100644 index 0000000..ecd67c3 --- /dev/null +++ b/src/main/kotlin/features/texturepack/NotPredicate.kt @@ -0,0 +1,18 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import net.minecraft.item.ItemStack + +class NotPredicate(val children: Array<FirmamentModelPredicate>) : FirmamentModelPredicate { + override fun test(stack: ItemStack): Boolean { + return children.none { it.test(stack) } + } + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate { + return NotPredicate(CustomModelOverrideParser.parsePredicates(jsonElement as JsonObject).toTypedArray()) + } + } +} diff --git a/src/main/kotlin/features/texturepack/NumberMatcher.kt b/src/main/kotlin/features/texturepack/NumberMatcher.kt new file mode 100644 index 0000000..7e6665f --- /dev/null +++ b/src/main/kotlin/features/texturepack/NumberMatcher.kt @@ -0,0 +1,125 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive +import moe.nea.firmament.util.useMatch + +abstract class NumberMatcher { + abstract fun test(number: Number): Boolean + + + companion object { + fun parse(jsonElement: JsonElement): NumberMatcher? { + if (jsonElement is JsonPrimitive) { + if (jsonElement.isString) { + val string = jsonElement.asString + return parseRange(string) ?: parseOperator(string) + } + if (jsonElement.isNumber) { + val number = jsonElement.asNumber + val hasDecimals = (number.toString().contains(".")) + return MatchNumberExact(if (hasDecimals) number.toLong() else number.toDouble()) + } + } + return null + } + + private val intervalSpec = + "(?<beginningOpen>[\\[\\(])(?<beginning>[0-9.]+)?,(?<ending>[0-9.]+)?(?<endingOpen>[\\]\\)])" + .toPattern() + + fun parseRange(string: String): RangeMatcher? { + intervalSpec.useMatch<Nothing>(string) { + // Open in the set-theory sense, meaning does not include its end. + val beginningOpen = group("beginningOpen") == "(" + val endingOpen = group("endingOpen") == ")" + val beginning = group("beginning")?.toDouble() + val ending = group("ending")?.toDouble() + return RangeMatcher(beginning, !beginningOpen, ending, !endingOpen) + } + return null + } + + enum class Operator(val operator: String) { + LESS("<") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult < 0 + } + }, + LESS_EQUALS("<=") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult <= 0 + } + }, + GREATER(">") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult > 0 + } + }, + GREATER_EQUALS(">=") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult >= 0 + } + }, + ; + + abstract fun matches(comparisonResult: Int): Boolean + } + + private val operatorPattern = "(?<operator>${Operator.entries.joinToString("|") {it.operator}})(?<value>[0-9.]+)".toPattern() + + fun parseOperator(string: String): OperatorMatcher? { + operatorPattern.useMatch<Nothing>(string) { + val operatorName = group("operator") + val operator = Operator.entries.find { it.operator == operatorName }!! + val value = group("value").toDouble() + return OperatorMatcher(operator, value) + } + return null + } + + data class OperatorMatcher(val operator: Operator, val value: Double) : NumberMatcher() { + override fun test(number: Number): Boolean { + return operator.matches(number.toDouble().compareTo(value)) + } + } + + + data class MatchNumberExact(val number: Number) : NumberMatcher() { + override fun test(number: Number): Boolean { + return when (this.number) { + is Double -> number.toDouble() == this.number.toDouble() + else -> number.toLong() == this.number.toLong() + } + } + } + + data class RangeMatcher( + val beginning: Double?, + val beginningInclusive: Boolean, + val ending: Double?, + val endingInclusive: Boolean, + ) : NumberMatcher() { + override fun test(number: Number): Boolean { + val value = number.toDouble() + if (beginning != null) { + if (beginningInclusive) { + if (value < beginning) return false + } else { + if (value <= beginning) return false + } + } + if (ending != null) { + if (endingInclusive) { + if (value > ending) return false + } else { + if (value >= ending) return false + } + } + return true + } + } + } + +} diff --git a/src/main/kotlin/features/texturepack/OrPredicate.kt b/src/main/kotlin/features/texturepack/OrPredicate.kt new file mode 100644 index 0000000..32f556b --- /dev/null +++ b/src/main/kotlin/features/texturepack/OrPredicate.kt @@ -0,0 +1,26 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import net.minecraft.item.ItemStack + +class OrPredicate(val children: Array<FirmamentModelPredicate>) : FirmamentModelPredicate { + override fun test(stack: ItemStack): Boolean { + return children.any { it.test(stack) } + } + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate { + val children = + (jsonElement as JsonArray) + .flatMap { + CustomModelOverrideParser.parsePredicates(it as JsonObject) + } + .toTypedArray() + return OrPredicate(children) + } + + } +} diff --git a/src/main/kotlin/features/texturepack/PetPredicate.kt b/src/main/kotlin/features/texturepack/PetPredicate.kt new file mode 100644 index 0000000..5e5d750 --- /dev/null +++ b/src/main/kotlin/features/texturepack/PetPredicate.kt @@ -0,0 +1,66 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import net.minecraft.item.ItemStack +import moe.nea.firmament.repo.ExpLadders +import moe.nea.firmament.util.petData + +class PetPredicate( + val petId: StringMatcher?, + val tier: RarityMatcher?, + val exp: NumberMatcher?, + val candyUsed: NumberMatcher?, + val level: NumberMatcher?, +) : FirmamentModelPredicate { + + override fun test(stack: ItemStack): Boolean { + val petData = stack.petData ?: return false + if (petId != null) { + if (!petId.matches(petData.type)) return false + } + if (exp != null) { + if (!exp.test(petData.exp)) return false + } + if (candyUsed != null) { + if (!candyUsed.test(petData.candyUsed)) return false + } + if (tier != null) { + if (!tier.match(petData.tier)) return false + } + val levelData by lazy(LazyThreadSafetyMode.NONE) { + ExpLadders.getExpLadder(petData.type, petData.tier) + .getPetLevel(petData.exp) + } + if (level != null) { + if (!level.test(levelData.currentLevel)) return false + } + return true + } + + object Parser : FirmamentModelPredicateParser { + override fun parse(jsonElement: JsonElement): FirmamentModelPredicate? { + if (jsonElement.isJsonPrimitive) { + return PetPredicate(StringMatcher.Equals(jsonElement.asString, false), null, null, null, null) + } + if (jsonElement !is JsonObject) return null + val idMatcher = jsonElement["id"]?.let(StringMatcher::parse) + val expMatcher = jsonElement["exp"]?.let(NumberMatcher::parse) + val levelMatcher = jsonElement["level"]?.let(NumberMatcher::parse) + val candyMatcher = jsonElement["candyUsed"]?.let(NumberMatcher::parse) + val tierMatcher = jsonElement["tier"]?.let(RarityMatcher::parse) + return PetPredicate( + idMatcher, + tierMatcher, + expMatcher, + candyMatcher, + levelMatcher, + ) + } + } + + override fun toString(): String { + return super.toString() + } +} diff --git a/src/main/kotlin/features/texturepack/RarityMatcher.kt b/src/main/kotlin/features/texturepack/RarityMatcher.kt new file mode 100644 index 0000000..634a171 --- /dev/null +++ b/src/main/kotlin/features/texturepack/RarityMatcher.kt @@ -0,0 +1,69 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonElement +import io.github.moulberry.repo.data.Rarity +import moe.nea.firmament.util.useMatch + +abstract class RarityMatcher { + abstract fun match(rarity: Rarity): Boolean + + companion object { + fun parse(jsonElement: JsonElement): RarityMatcher { + val string = jsonElement.asString + val range = parseRange(string) + if (range != null) return range + return Exact(Rarity.valueOf(string)) + } + + private val allRarities = Rarity.entries.joinToString("|", "(?:", ")") + private val intervalSpec = + "(?<beginningOpen>[\\[\\(])(?<beginning>$allRarities)?,(?<ending>$allRarities)?(?<endingOpen>[\\]\\)])" + .toPattern() + + fun parseRange(string: String): RangeMatcher? { + intervalSpec.useMatch<Nothing>(string) { + // Open in the set-theory sense, meaning does not include its end. + val beginningOpen = group("beginningOpen") == "(" + val endingOpen = group("endingOpen") == ")" + val beginning = group("beginning")?.let(Rarity::valueOf) + val ending = group("ending")?.let(Rarity::valueOf) + return RangeMatcher(beginning, !beginningOpen, ending, !endingOpen) + } + return null + } + + } + + data class Exact(val expected: Rarity) : RarityMatcher() { + override fun match(rarity: Rarity): Boolean { + return rarity == expected + } + } + + data class RangeMatcher( + val beginning: Rarity?, + val beginningInclusive: Boolean, + val ending: Rarity?, + val endingInclusive: Boolean, + ) : RarityMatcher() { + override fun match(rarity: Rarity): Boolean { + if (beginning != null) { + if (beginningInclusive) { + if (rarity < beginning) return false + } else { + if (rarity <= beginning) return false + } + } + if (ending != null) { + if (endingInclusive) { + if (rarity > ending) return false + } else { + if (rarity >= ending) return false + } + } + return true + } + } + +} diff --git a/src/main/kotlin/features/texturepack/StringMatcher.kt b/src/main/kotlin/features/texturepack/StringMatcher.kt new file mode 100644 index 0000000..5eb86ac --- /dev/null +++ b/src/main/kotlin/features/texturepack/StringMatcher.kt @@ -0,0 +1,159 @@ + +package moe.nea.firmament.features.texturepack + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import com.google.gson.internal.LazilyParsedNumber +import java.util.function.Predicate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.minecraft.nbt.NbtString +import net.minecraft.text.Text +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.removeColorCodes + +@Serializable(with = StringMatcher.Serializer::class) +interface StringMatcher { + fun matches(string: String): Boolean + fun matches(text: Text): Boolean { + return matches(text.string) + } + + fun matches(nbt: NbtString): Boolean { + val string = nbt.asString() + val jsonStart = string.indexOf('{') + val stringStart = string.indexOf('"') + val isString = stringStart >= 0 && string.subSequence(0, stringStart).isBlank() + val isJson = jsonStart >= 0 && string.subSequence(0, jsonStart).isBlank() + if (isString || isJson) + return matches(Text.Serialization.fromJson(string, MC.defaultRegistries) ?: return false) + return matches(string) + } + + class Equals(input: String, val stripColorCodes: Boolean) : StringMatcher { + private val expected = if (stripColorCodes) input.removeColorCodes() else input + override fun matches(string: String): Boolean { + return expected == (if (stripColorCodes) string.removeColorCodes() else string) + } + + override fun toString(): String { + return "Equals($expected, stripColorCodes = $stripColorCodes)" + } + } + + class Pattern(val patternWithColorCodes: String, val stripColorCodes: Boolean) : StringMatcher { + private val regex: Predicate<String> = patternWithColorCodes.toPattern().asMatchPredicate() + override fun matches(string: String): Boolean { + return regex.test(if (stripColorCodes) string.removeColorCodes() else string) + } + + override fun toString(): String { + return "Pattern($patternWithColorCodes, stripColorCodes = $stripColorCodes)" + } + } + + object Serializer : KSerializer<StringMatcher> { + val delegateSerializer = kotlinx.serialization.json.JsonElement.serializer() + override val descriptor: SerialDescriptor + get() = SerialDescriptor("StringMatcher", delegateSerializer.descriptor) + + override fun deserialize(decoder: Decoder): StringMatcher { + val delegate = decoder.decodeSerializableValue(delegateSerializer) + val gsonDelegate = delegate.intoGson() + return parse(gsonDelegate) + } + + override fun serialize(encoder: Encoder, value: StringMatcher) { + encoder.encodeSerializableValue(delegateSerializer, Companion.serialize(value).intoKotlinJson()) + } + + } + + companion object { + fun serialize(stringMatcher: StringMatcher): JsonElement { + TODO("Cannot serialize string matchers rn") + } + + fun parse(jsonElement: JsonElement): StringMatcher { + if (jsonElement is JsonPrimitive) { + return Equals(jsonElement.asString, true) + } + if (jsonElement is JsonObject) { + val regex = jsonElement["regex"] as JsonPrimitive? + val text = jsonElement["equals"] as JsonPrimitive? + val shouldStripColor = when (val color = (jsonElement["color"] as JsonPrimitive?)?.asString) { + "preserve" -> false + "strip", null -> true + else -> error("Unknown color preservation mode: $color") + } + if ((regex == null) == (text == null)) error("Could not parse $jsonElement as string matcher") + if (regex != null) + return Pattern(regex.asString, shouldStripColor) + if (text != null) + return Equals(text.asString, shouldStripColor) + } + error("Could not parse $jsonElement as a string matcher") + } + } +} + +fun JsonElement.intoKotlinJson(): kotlinx.serialization.json.JsonElement { + when (this) { + is JsonNull -> return kotlinx.serialization.json.JsonNull + is JsonObject -> { + return kotlinx.serialization.json.JsonObject(this.entrySet() + .associate { it.key to it.value.intoKotlinJson() }) + } + + is JsonArray -> { + return kotlinx.serialization.json.JsonArray(this.map { it.intoKotlinJson() }) + } + + is JsonPrimitive -> { + if (this.isString) + return kotlinx.serialization.json.JsonPrimitive(this.asString) + if (this.isBoolean) + return kotlinx.serialization.json.JsonPrimitive(this.asBoolean) + return kotlinx.serialization.json.JsonPrimitive(this.asNumber) + } + + else -> error("Unknown json variant $this") + } +} + +fun kotlinx.serialization.json.JsonElement.intoGson(): JsonElement { + when (this) { + is kotlinx.serialization.json.JsonNull -> return JsonNull.INSTANCE + is kotlinx.serialization.json.JsonPrimitive -> { + if (this.isString) + return JsonPrimitive(this.content) + if (this.content == "true") + return JsonPrimitive(true) + if (this.content == "false") + return JsonPrimitive(false) + return JsonPrimitive(LazilyParsedNumber(this.content)) + } + + is kotlinx.serialization.json.JsonObject -> { + val obj = JsonObject() + for ((k, v) in this) { + obj.add(k, v.intoGson()) + } + return obj + } + + is kotlinx.serialization.json.JsonArray -> { + val arr = JsonArray() + for (v in this) { + arr.add(v.intoGson()) + } + return arr + } + } +} diff --git a/src/main/kotlin/features/world/FairySouls.kt b/src/main/kotlin/features/world/FairySouls.kt new file mode 100644 index 0000000..8a8291a --- /dev/null +++ b/src/main/kotlin/features/world/FairySouls.kt @@ -0,0 +1,131 @@ + + +package moe.nea.firmament.features.world + +import io.github.moulberry.repo.data.Coordinate +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import net.minecraft.text.Text +import net.minecraft.util.math.Vec3d +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.events.SkyblockServerUpdateEvent +import moe.nea.firmament.events.WorldRenderLastEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.repo.RepoManager +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SBData +import moe.nea.firmament.util.SkyBlockIsland +import moe.nea.firmament.util.blockPos +import moe.nea.firmament.util.data.ProfileSpecificDataHolder +import moe.nea.firmament.util.render.RenderInWorldContext +import moe.nea.firmament.util.render.RenderInWorldContext.Companion.renderInWorld +import moe.nea.firmament.util.unformattedString + + +object FairySouls : FirmamentFeature { + + + @Serializable + data class Data( + val foundSouls: MutableMap<SkyBlockIsland, MutableSet<Int>> = mutableMapOf() + ) + + override val config: ManagedConfig + get() = TConfig + + object DConfig : ProfileSpecificDataHolder<Data>(serializer(), "found-fairysouls", ::Data) + + + object TConfig : ManagedConfig("fairy-souls") { + val displaySouls by toggle("show") { false } + val resetSouls by button("reset") { + DConfig.data?.foundSouls?.clear() != null + updateMissingSouls() + } + } + + + override val identifier: String get() = "fairy-souls" + + val playerReach = 5 + val playerReachSquared = playerReach * playerReach + + var currentLocationName: SkyBlockIsland? = null + var currentLocationSouls: List<Coordinate> = emptyList() + var currentMissingSouls: List<Coordinate> = emptyList() + + fun updateMissingSouls() { + currentMissingSouls = emptyList() + val c = DConfig.data ?: return + val fi = c.foundSouls[currentLocationName] ?: setOf() + val cms = currentLocationSouls.toMutableList() + fi.asSequence().sortedDescending().filter { it in cms.indices }.forEach { cms.removeAt(it) } + currentMissingSouls = cms + } + + fun updateWorldSouls() { + currentLocationSouls = emptyList() + val loc = currentLocationName ?: return + currentLocationSouls = RepoManager.neuRepo.constants.fairySouls.soulLocations[loc.locrawMode] ?: return + } + + fun findNearestClickableSoul(): Coordinate? { + val player = MC.player ?: return null + val pos = player.pos + val location = SBData.skyblockLocation ?: return null + val soulLocations: List<Coordinate> = + RepoManager.neuRepo.constants.fairySouls.soulLocations[location.locrawMode] ?: return null + return soulLocations + .map { it to it.blockPos.getSquaredDistance(pos) } + .filter { it.second < playerReachSquared } + .minByOrNull { it.second } + ?.first + } + + private fun markNearestSoul() { + val nearestSoul = findNearestClickableSoul() ?: return + val c = DConfig.data ?: return + val loc = currentLocationName ?: return + val idx = currentLocationSouls.indexOf(nearestSoul) + c.foundSouls.computeIfAbsent(loc) { mutableSetOf() }.add(idx) + DConfig.markDirty() + updateMissingSouls() + } + + @Subscribe + fun onWorldRender(it: WorldRenderLastEvent) { + if (!TConfig.displaySouls) return + renderInWorld(it) { + color(1F, 1F, 0F, 0.8F) + currentMissingSouls.forEach { + block(it.blockPos) + } + color(1f, 0f, 1f, 1f) + currentLocationSouls.forEach { + wireframeCube(it.blockPos) + } + } + } + + @Subscribe + fun onProcessChat(it: ProcessChatEvent) { + when (it.text.unformattedString) { + "You have already found that Fairy Soul!" -> { + markNearestSoul() + } + + "SOUL! You found a Fairy Soul!" -> { + markNearestSoul() + } + } + } + + @Subscribe + fun onLocationChange(it: SkyblockServerUpdateEvent) { + currentLocationName = it.newLocraw?.skyblockLocation + updateWorldSouls() + updateMissingSouls() + } +} diff --git a/src/main/kotlin/features/world/NPCWaypoints.kt b/src/main/kotlin/features/world/NPCWaypoints.kt new file mode 100644 index 0000000..592b8fa --- /dev/null +++ b/src/main/kotlin/features/world/NPCWaypoints.kt @@ -0,0 +1,40 @@ +package moe.nea.firmament.features.world + +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.commands.thenExecute +import moe.nea.firmament.events.CommandEvent +import moe.nea.firmament.events.ReloadRegistrationEvent +import moe.nea.firmament.util.MoulConfigUtils +import moe.nea.firmament.util.ScreenUtil + +object NPCWaypoints { + + var allNpcWaypoints = listOf<NavigableWaypoint>() + + @Subscribe + fun onRepoReloadRegistration(event: ReloadRegistrationEvent) { + event.repo.registerReloadListener { + allNpcWaypoints = it.items.items.values + .asSequence() + .filter { !it.island.isNullOrBlank() } + .map { + NavigableWaypoint.NPCWaypoint(it) + } + .toList() + } + } + + @Subscribe + fun onOpenGui(event: CommandEvent.SubCommand) { + event.subcommand("npcs") { + thenExecute { + ScreenUtil.setScreenLater(MoulConfigUtils.loadScreen( + "npc_waypoints", + NpcWaypointGui(allNpcWaypoints), + null)) + } + } + } + + +} diff --git a/src/main/kotlin/features/world/NavigableWaypoint.kt b/src/main/kotlin/features/world/NavigableWaypoint.kt new file mode 100644 index 0000000..28a517f --- /dev/null +++ b/src/main/kotlin/features/world/NavigableWaypoint.kt @@ -0,0 +1,22 @@ +package moe.nea.firmament.features.world + +import io.github.moulberry.repo.data.NEUItem +import net.minecraft.util.math.BlockPos +import moe.nea.firmament.util.SkyBlockIsland + +abstract class NavigableWaypoint { + abstract val name: String + abstract val position: BlockPos + abstract val island: SkyBlockIsland + + data class NPCWaypoint( + val item: NEUItem, + ) : NavigableWaypoint() { + override val name: String + get() = item.displayName + override val position: BlockPos + get() = BlockPos(item.x, item.y, item.z) + override val island: SkyBlockIsland + get() = SkyBlockIsland.forMode(item.island) + } +} diff --git a/src/main/kotlin/features/world/NavigationHelper.kt b/src/main/kotlin/features/world/NavigationHelper.kt new file mode 100644 index 0000000..acdfb86 --- /dev/null +++ b/src/main/kotlin/features/world/NavigationHelper.kt @@ -0,0 +1,121 @@ +package moe.nea.firmament.features.world + +import io.github.moulberry.repo.constants.Islands +import net.minecraft.text.Text +import net.minecraft.util.math.BlockPos +import net.minecraft.util.math.Position +import net.minecraft.util.math.Vec3i +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.SkyblockServerUpdateEvent +import moe.nea.firmament.events.TickEvent +import moe.nea.firmament.events.WorldRenderLastEvent +import moe.nea.firmament.repo.RepoManager +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.SBData +import moe.nea.firmament.util.SkyBlockIsland +import moe.nea.firmament.util.WarpUtil +import moe.nea.firmament.util.render.RenderInWorldContext + +object NavigationHelper { + var targetWaypoint: NavigableWaypoint? = null + set(value) { + field = value + recalculateRoute() + } + + var nextTeleporter: Islands.Teleporter? = null + private set + + val Islands.Teleporter.toIsland get() = SkyBlockIsland.forMode(this.getTo()) + val Islands.Teleporter.fromIsland get() = SkyBlockIsland.forMode(this.getFrom()) + val Islands.Teleporter.blockPos get() = BlockPos(x.toInt(), y.toInt(), z.toInt()) + + @Subscribe + fun onWorldSwitch(event: SkyblockServerUpdateEvent) { + recalculateRoute() + } + + fun recalculateRoute() { + val tp = targetWaypoint + val currentIsland = SBData.skyblockLocation + if (tp == null || currentIsland == null) { + nextTeleporter = null + return + } + val route = findRoute(currentIsland, tp.island, mutableSetOf()) + nextTeleporter = route?.get(0) + } + + private fun findRoute( + fromIsland: SkyBlockIsland, + targetIsland: SkyBlockIsland, + visitedIslands: MutableSet<SkyBlockIsland> + ): MutableList<Islands.Teleporter>? { + var shortestChain: MutableList<Islands.Teleporter>? = null + for (it in RepoManager.neuRepo.constants.islands.teleporters) { + if (it.toIsland in visitedIslands) continue + if (it.fromIsland != fromIsland) continue + if (it.toIsland == targetIsland) return mutableListOf(it) + visitedIslands.add(fromIsland) + val nextRoute = findRoute(it.toIsland, targetIsland, visitedIslands) ?: continue + nextRoute.add(0, it) + if (shortestChain == null || shortestChain.size > nextRoute.size) { + shortestChain = nextRoute + } + visitedIslands.remove(fromIsland) + } + return shortestChain + } + + + @Subscribe + fun onMovement(event: TickEvent) { // TODO: add a movement tick event maybe? + val tp = targetWaypoint ?: return + val p = MC.player ?: return + if (p.squaredDistanceTo(tp.position.toCenterPos()) < 5 * 5) { + targetWaypoint = null + } + } + + @Subscribe + fun drawWaypoint(event: WorldRenderLastEvent) { + val tp = targetWaypoint ?: return + val nt = nextTeleporter + RenderInWorldContext.renderInWorld(event) { + if (nt != null) { + waypoint(nt.blockPos, + Text.literal("Teleporter to " + nt.toIsland.userFriendlyName), + Text.literal("(towards " + tp.name + "§f)")) + } else if (tp.island == SBData.skyblockLocation) { + waypoint(tp.position, + Text.literal(tp.name)) + } + } + } + + fun tryWarpNear() { + val tp = targetWaypoint + if (tp == null) { + MC.sendChat(Text.literal("Could not find a waypoint to warp you to. Select one first.")) + return + } + WarpUtil.teleportToNearestWarp(tp.island, tp.position.asPositionView()) + } + +} + +fun Vec3i.asPositionView(): Position { + return object : Position { + override fun getX(): Double { + return this@asPositionView.x.toDouble() + } + + override fun getY(): Double { + return this@asPositionView.y.toDouble() + } + + override fun getZ(): Double { + return this@asPositionView.z.toDouble() + } + } +} diff --git a/src/main/kotlin/features/world/NpcWaypointGui.kt b/src/main/kotlin/features/world/NpcWaypointGui.kt new file mode 100644 index 0000000..6146e50 --- /dev/null +++ b/src/main/kotlin/features/world/NpcWaypointGui.kt @@ -0,0 +1,68 @@ +package moe.nea.firmament.features.world + +import io.github.notenoughupdates.moulconfig.observer.ObservableList +import io.github.notenoughupdates.moulconfig.xml.Bind +import moe.nea.firmament.features.events.anniversity.AnniversaryFeatures.atOnce +import moe.nea.firmament.keybindings.SavedKeyBinding + +class NpcWaypointGui( + val allWaypoints: List<NavigableWaypoint>, +) { + + data class NavigableWaypointW(val waypoint: NavigableWaypoint) { + @Bind + fun name() = waypoint.name + + @Bind + fun isSelected() = NavigationHelper.targetWaypoint == waypoint + + @Bind + fun click() { + if (SavedKeyBinding.isShiftDown()) { + NavigationHelper.targetWaypoint = waypoint + NavigationHelper.tryWarpNear() + } else if (isSelected()) { + NavigationHelper.targetWaypoint = null + } else { + NavigationHelper.targetWaypoint = waypoint + } + } + } + + @JvmField + @field:Bind + var search: String = "" + var lastSearch: String? = null + + @Bind("results") + fun results(): ObservableList<NavigableWaypointW> { + return results + } + + @Bind + fun tick() { + if (search != lastSearch) { + updateSearch() + lastSearch = search + } + } + + val results: ObservableList<NavigableWaypointW> = ObservableList(mutableListOf()) + + fun updateSearch() { + val split = search.split(" +".toRegex()) + results.atOnce { + results.clear() + allWaypoints.filter { waypoint -> + if (search.isBlank()) { + true + } else { + split.all { waypoint.name.contains(it, ignoreCase = true) } + } + }.mapTo(results) { + NavigableWaypointW(it) + } + } + } + +} diff --git a/src/main/kotlin/features/world/Waypoints.kt b/src/main/kotlin/features/world/Waypoints.kt new file mode 100644 index 0000000..91a06da --- /dev/null +++ b/src/main/kotlin/features/world/Waypoints.kt @@ -0,0 +1,297 @@ + + +package moe.nea.firmament.features.world + +import com.mojang.brigadier.arguments.IntegerArgumentType +import me.shedaniel.math.Color +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource +import kotlinx.serialization.Serializable +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds +import net.minecraft.command.argument.BlockPosArgumentType +import net.minecraft.server.command.ServerCommandSource +import net.minecraft.text.Text +import net.minecraft.util.math.BlockPos +import net.minecraft.util.math.Vec3d +import moe.nea.firmament.Firmament +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.commands.get +import moe.nea.firmament.commands.thenArgument +import moe.nea.firmament.commands.thenExecute +import moe.nea.firmament.commands.thenLiteral +import moe.nea.firmament.events.CommandEvent +import moe.nea.firmament.events.ProcessChatEvent +import moe.nea.firmament.events.TickEvent +import moe.nea.firmament.events.WorldReadyEvent +import moe.nea.firmament.events.WorldRenderLastEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.ClipboardUtils +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.TimeMark +import moe.nea.firmament.util.render.RenderInWorldContext + +object Waypoints : FirmamentFeature { + override val identifier: String + get() = "waypoints" + + object TConfig : ManagedConfig(identifier) { + val tempWaypointDuration by duration("temp-waypoint-duration", 0.seconds, 1.hours) { 30.seconds } + val showIndex by toggle("show-index") { true } + val skipToNearest by toggle("skip-to-nearest") { false } + // TODO: look ahead size + } + + data class TemporaryWaypoint( + val pos: BlockPos, + val postedAt: TimeMark, + ) + + override val config get() = TConfig + + val temporaryPlayerWaypointList = mutableMapOf<String, TemporaryWaypoint>() + val temporaryPlayerWaypointMatcher = "(?i)x: (-?[0-9]+),? y: (-?[0-9]+),? z: (-?[0-9]+)".toPattern() + + val waypoints = mutableListOf<BlockPos>() + var ordered = false + var orderedIndex = 0 + + @Serializable + data class ColeWeightWaypoint( + val x: Int, + val y: Int, + val z: Int, + val r: Int = 0, + val g: Int = 0, + val b: Int = 0, + ) + + @Subscribe + fun onRenderOrderedWaypoints(event: WorldRenderLastEvent) { + if (waypoints.isEmpty()) return + RenderInWorldContext.renderInWorld(event) { + if (!ordered) { + waypoints.withIndex().forEach { + color(0f, 0.3f, 0.7f, 0.5f) + block(it.value) + color(1f, 1f, 1f, 1f) + if (TConfig.showIndex) + withFacingThePlayer(it.value.toCenterPos()) { + text(Text.literal(it.index.toString())) + } + } + } else { + orderedIndex %= waypoints.size + val firstColor = Color.ofRGBA(0, 200, 40, 180) + color(firstColor) + tracer(waypoints[orderedIndex].toCenterPos(), lineWidth = 3f) + waypoints.withIndex().toList() + .wrappingWindow(orderedIndex, 3) + .zip( + listOf( + firstColor, + Color.ofRGBA(180, 200, 40, 150), + Color.ofRGBA(180, 80, 20, 140), + ) + ) + .reversed() + .forEach { (waypoint, col) -> + val (index, pos) = waypoint + color(col) + block(pos) + color(1f, 1f, 1f, 1f) + if (TConfig.showIndex) + withFacingThePlayer(pos.toCenterPos()) { + text(Text.literal(index.toString())) + } + } + } + } + } + + @Subscribe + fun onTick(event: TickEvent) { + if (waypoints.isEmpty() || !ordered) return + orderedIndex %= waypoints.size + val p = MC.player?.pos ?: return + if (TConfig.skipToNearest) { + orderedIndex = + (waypoints.withIndex().minBy { it.value.getSquaredDistance(p) }.index + 1) % waypoints.size + } else { + if (waypoints[orderedIndex].isWithinDistance(p, 3.0)) { + orderedIndex = (orderedIndex + 1) % waypoints.size + } + } + } + + @Subscribe + fun onProcessChat(it: ProcessChatEvent) { + val matcher = temporaryPlayerWaypointMatcher.matcher(it.unformattedString) + if (it.nameHeuristic != null && TConfig.tempWaypointDuration > 0.seconds && matcher.find()) { + temporaryPlayerWaypointList[it.nameHeuristic] = TemporaryWaypoint( + BlockPos( + matcher.group(1).toInt(), + matcher.group(2).toInt(), + matcher.group(3).toInt(), + ), + TimeMark.now() + ) + } + } + + @Subscribe + fun onCommand(event: CommandEvent.SubCommand) { + event.subcommand("waypoint") { + thenArgument("pos", BlockPosArgumentType.blockPos()) { pos -> + thenExecute { + val position = pos.get(this).toAbsoluteBlockPos(source.asFakeServer()) + waypoints.add(position) + source.sendFeedback( + Text.stringifiedTranslatable( + "firmament.command.waypoint.added", + position.x, + position.y, + position.z + ) + ) + } + } + } + event.subcommand("waypoints") { + thenLiteral("clear") { + thenExecute { + waypoints.clear() + source.sendFeedback(Text.translatable("firmament.command.waypoint.clear")) + } + } + thenLiteral("toggleordered") { + thenExecute { + ordered = !ordered + if (ordered) { + val p = MC.player?.pos ?: Vec3d.ZERO + orderedIndex = + waypoints.withIndex().minByOrNull { it.value.getSquaredDistance(p) }?.index ?: 0 + } + source.sendFeedback(Text.translatable("firmament.command.waypoint.ordered.toggle.$ordered")) + } + } + thenLiteral("skip") { + thenExecute { + if (ordered && waypoints.isNotEmpty()) { + orderedIndex = (orderedIndex + 1) % waypoints.size + source.sendFeedback(Text.translatable("firmament.command.waypoint.skip")) + } else { + source.sendError(Text.translatable("firmament.command.waypoint.skip.error")) + } + } + } + thenLiteral("remove") { + thenArgument("index", IntegerArgumentType.integer(0)) { indexArg -> + thenExecute { + val index = get(indexArg) + if (index in waypoints.indices) { + waypoints.removeAt(index) + source.sendFeedback(Text.stringifiedTranslatable( + "firmament.command.waypoint.remove", + index)) + } else { + source.sendError(Text.stringifiedTranslatable("firmament.command.waypoint.remove.error")) + } + } + } + } + thenLiteral("import") { + thenExecute { + val contents = ClipboardUtils.getTextContents() + val data = try { + Firmament.json.decodeFromString<List<ColeWeightWaypoint>>(contents) + } catch (ex: Exception) { + Firmament.logger.error("Could not load waypoints from clipboard", ex) + source.sendError(Text.translatable("firmament.command.waypoint.import.error")) + return@thenExecute + } + waypoints.clear() + data.mapTo(waypoints) { BlockPos(it.x, it.y, it.z) } + source.sendFeedback( + Text.stringifiedTranslatable( + "firmament.command.waypoint.import", + data.size + ) + ) + } + } + } + } + + @Subscribe + fun onRenderTemporaryWaypoints(event: WorldRenderLastEvent) { + temporaryPlayerWaypointList.entries.removeIf { it.value.postedAt.passedTime() > TConfig.tempWaypointDuration } + if (temporaryPlayerWaypointList.isEmpty()) return + RenderInWorldContext.renderInWorld(event) { + color(1f, 1f, 0f, 1f) + temporaryPlayerWaypointList.forEach { (player, waypoint) -> + block(waypoint.pos) + } + color(1f, 1f, 1f, 1f) + temporaryPlayerWaypointList.forEach { (player, waypoint) -> + val skin = + MC.networkHandler?.listedPlayerListEntries?.find { it.profile.name == player } + ?.skinTextures + ?.texture + withFacingThePlayer(waypoint.pos.toCenterPos()) { + waypoint(waypoint.pos, Text.stringifiedTranslatable("firmament.waypoint.temporary", player)) + if (skin != null) { + matrixStack.translate(0F, -20F, 0F) + // Head front + texture( + skin, 16, 16, + 1 / 8f, 1 / 8f, + 2 / 8f, 2 / 8f, + ) + // Head overlay + texture( + skin, 16, 16, + 5 / 8f, 1 / 8f, + 6 / 8f, 2 / 8f, + ) + } + } + } + } + } + + @Subscribe + fun onWorldReady(event: WorldReadyEvent) { + temporaryPlayerWaypointList.clear() + } +} + +fun <E> List<E>.wrappingWindow(startIndex: Int, windowSize: Int): List<E> { + val result = ArrayList<E>(windowSize) + if (startIndex + windowSize < size) { + result.addAll(subList(startIndex, startIndex + windowSize)) + } else { + result.addAll(subList(startIndex, size)) + result.addAll(subList(0, minOf(windowSize - (size - startIndex), startIndex))) + } + return result +} + + +fun FabricClientCommandSource.asFakeServer(): ServerCommandSource { + val source = this + return ServerCommandSource( + source.player, + source.position, + source.rotation, + null, + 0, + "FakeServerCommandSource", + Text.literal("FakeServerCommandSource"), + null, + source.player + ) +} |