diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index b250319a..b59c1e65 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -23,11 +23,11 @@ style: maxLineLength: 120 excludePackageStatements: true excludeImportStatements: true - excludeCommentStatements: false + excludeCommentStatements: true ReturnCount: active: true - max: 1 + max: 3 excludedFunctions: - equals @@ -44,7 +44,6 @@ style: ForbiddenComment: active: true comments: - - "TODO:" - "FIXME:" - "STOPSHIP:" @@ -58,11 +57,11 @@ complexity: CyclomaticComplexMethod: active: true - allowedComplexity: 5 + allowedComplexity: 6 NestedBlockDepth: active: true - allowedDepth: 2 + allowedDepth: 3 LongParameterList: active: true diff --git a/src/main/java/org/cobalt/mixin/client/AbstractClientPlayerAccessor.java b/src/main/java/org/cobalt/mixin/client/AbstractClientPlayerAccessor.java index bcafb250..10168103 100644 --- a/src/main/java/org/cobalt/mixin/client/AbstractClientPlayerAccessor.java +++ b/src/main/java/org/cobalt/mixin/client/AbstractClientPlayerAccessor.java @@ -8,6 +8,11 @@ @Mixin(AbstractClientPlayer.class) public interface AbstractClientPlayerAccessor { + /** + * Returns the client-side player info. + * + * @return the {@link net.minecraft.client.multiplayer.PlayerInfo} associated with this player + */ @Accessor("playerInfo") PlayerInfo getClientPlayerInfo(); diff --git a/src/main/java/org/cobalt/mixin/client/MinecraftMixin.java b/src/main/java/org/cobalt/mixin/client/MinecraftMixin.java index b5aa0814..376d8386 100644 --- a/src/main/java/org/cobalt/mixin/client/MinecraftMixin.java +++ b/src/main/java/org/cobalt/mixin/client/MinecraftMixin.java @@ -45,7 +45,7 @@ private void registerSkia(GameConfig gameConfig, CallbackInfo ci) { int finalWidth = Math.max(width[0], 1); int finalHeight = Math.max(height[0], 1); - SkiaContext.INSTANCE.initSkia(finalWidth, finalHeight); + SkiaContext.INSTANCE.initSkia$cobalt(finalWidth, finalHeight); } @Inject( @@ -57,7 +57,7 @@ private void registerSkia(GameConfig gameConfig, CallbackInfo ci) { require = 1 ) private void onBeforeFlipFrame(boolean advanceGameTime, CallbackInfo ci) { - SkiaContext.INSTANCE.draw(); + SkiaContext.INSTANCE.draw$cobalt(); } @Inject(method = "tick", at = @At("HEAD")) diff --git a/src/main/java/org/cobalt/mixin/gui/ChatScreenMixin.java b/src/main/java/org/cobalt/mixin/gui/ChatScreenMixin.java deleted file mode 100644 index 3f2bf163..00000000 --- a/src/main/java/org/cobalt/mixin/gui/ChatScreenMixin.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.cobalt.mixin.gui; - -import net.minecraft.client.gui.screens.ChatScreen; -import org.cobalt.event.EventBus; -import org.cobalt.event.impl.ChatSendEvent; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -@Mixin(ChatScreen.class) -public class ChatScreenMixin { - - @Inject(method = "handleChatInput", at = @At("HEAD"), cancellable = true) - private void handleChatMessage(String msg, boolean addToRecent, CallbackInfo ci) { - ChatSendEvent event = new ChatSendEvent(msg); - EventBus.post(event); - - if (event.isCancelled()) { - ci.cancel(); - } - } - - -} diff --git a/src/main/java/org/cobalt/mixin/gui/ClientPacketListenerMixin.java b/src/main/java/org/cobalt/mixin/gui/ClientPacketListenerMixin.java index 104f482b..b56cae84 100644 --- a/src/main/java/org/cobalt/mixin/gui/ClientPacketListenerMixin.java +++ b/src/main/java/org/cobalt/mixin/gui/ClientPacketListenerMixin.java @@ -2,6 +2,8 @@ import net.minecraft.client.multiplayer.ClientPacketListener; import org.cobalt.command.CommandManager; +import org.cobalt.event.EventBus; +import org.cobalt.event.impl.ChatSendEvent; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -12,8 +14,10 @@ public class ClientPacketListenerMixin { @Inject(method = "sendChat", at = @At("HEAD"), cancellable = true) public void sendChatMessage(String content, CallbackInfo ci) { - if (content.startsWith(String.valueOf(CommandManager.getPrefix()))) { - CommandManager.handleCommandExecution(content); + ChatSendEvent event = new ChatSendEvent(content); + EventBus.post(event); + + if (event.isCancelled()) { ci.cancel(); } } diff --git a/src/main/java/org/cobalt/mixin/gui/CommandSuggestionsMixin.java b/src/main/java/org/cobalt/mixin/gui/CommandSuggestionsMixin.java index 94662eb7..f828a05f 100644 --- a/src/main/java/org/cobalt/mixin/gui/CommandSuggestionsMixin.java +++ b/src/main/java/org/cobalt/mixin/gui/CommandSuggestionsMixin.java @@ -64,7 +64,7 @@ public void refresh(CallbackInfo ci, @Local(name = "reader") StringReader reader return; } - if (!reader.canRead() || reader.peek() != CommandManager.getPrefix()) { + if (!reader.canRead() || reader.peek() != CommandManager.PREFIX) { reader.setCursor(0); return; } @@ -72,7 +72,7 @@ public void refresh(CallbackInfo ci, @Local(name = "reader") StringReader reader reader.skip(); int cursor = this.input.getCursorPosition(); - CommandDispatcher dispatcher = CommandManager.getDispatcher(); + CommandDispatcher dispatcher = CommandManager.getDispatcher$cobalt(); if (this.currentParse == null) { ClientSuggestionProvider suggestionProvider = this.minecraft.player.connection.getSuggestionsProvider(); diff --git a/src/main/java/org/cobalt/mixin/platform/WindowMixin.java b/src/main/java/org/cobalt/mixin/platform/WindowMixin.java index 7dccad38..77baedd1 100644 --- a/src/main/java/org/cobalt/mixin/platform/WindowMixin.java +++ b/src/main/java/org/cobalt/mixin/platform/WindowMixin.java @@ -33,7 +33,7 @@ private void onFramebufferResize(long handle, int newWidth, int newHeight, Callb int finalWidth = Math.max(newWidth, 1); int finalHeight = Math.max(newHeight, 1); - SkiaContext.INSTANCE.initSkia(finalWidth, finalHeight); + SkiaContext.INSTANCE.initSkia$cobalt(finalWidth, finalHeight); } } diff --git a/src/main/java/org/cobalt/mixin/render/FrustumInvoker.java b/src/main/java/org/cobalt/mixin/render/FrustumInvoker.java index 1b74d5d2..b8649f6d 100644 --- a/src/main/java/org/cobalt/mixin/render/FrustumInvoker.java +++ b/src/main/java/org/cobalt/mixin/render/FrustumInvoker.java @@ -7,6 +7,19 @@ @Mixin(Frustum.class) public interface FrustumInvoker { + /** + * Tests whether an axis-aligned bounding box is inside, intersecting, or outside the view frustum. + * + * @param minX the x-coordinate of the minimum corner + * @param minY the y-coordinate of the minimum corner + * @param minZ the z-coordinate of the minimum corner + * @param maxX the x-coordinate of the maximum corner + * @param maxY the y-coordinate of the maximum corner + * @param maxZ the z-coordinate of the maximum corner + * @return {@link org.joml.FrustumIntersection#INSIDE}, + * {@link org.joml.FrustumIntersection#INTERSECT}, + * or a plane index if outside the frustum + */ @Invoker int invokeCubeInFrustum(double minX, double minY, double minZ, double maxX, double maxY, double maxZ); diff --git a/src/main/kotlin/org/cobalt/Cobalt.kt b/src/main/kotlin/org/cobalt/Cobalt.kt index af43eb68..ae2a1bf5 100644 --- a/src/main/kotlin/org/cobalt/Cobalt.kt +++ b/src/main/kotlin/org/cobalt/Cobalt.kt @@ -5,16 +5,29 @@ import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents import net.fabricmc.loader.api.FabricLoader import net.fabricmc.loader.api.ModContainer import net.minecraft.client.Minecraft +import org.cobalt.Cobalt.MOD_CONTAINER +import org.cobalt.Cobalt.MOD_NAME +import org.cobalt.Cobalt.MOD_VERSION +import org.cobalt.Cobalt.minecraft import org.cobalt.command.CommandManager import org.cobalt.command.impl.MainCommand import org.cobalt.event.EventBus import org.cobalt.event.impl.WorldRenderEvent import org.cobalt.module.ModuleManager +/** + * Main mod entrypoint and contains shared constants for Cobalt. + * + * @property minecraft global Minecraft client instance + * @property MOD_CONTAINER Fabric mod container for this mod + * @property MOD_NAME display name of the mod from metadata + * @property MOD_VERSION version string from mod metadata + */ object Cobalt : ClientModInitializer { - @JvmField - val minecraft: Minecraft = Minecraft.getInstance() + @JvmStatic + val minecraft: Minecraft + get() = Minecraft.getInstance() @JvmField val MOD_CONTAINER: ModContainer = FabricLoader.getInstance().getModContainer("cobalt").orElseThrow() @@ -29,7 +42,6 @@ object Cobalt : ClientModInitializer { ModuleManager.registerModules() CommandManager.register(MainCommand) - // Dispatch Events LevelRenderEvents.END_MAIN.register { context -> EventBus.post(WorldRenderEvent(context)) } diff --git a/src/main/kotlin/org/cobalt/command/Command.kt b/src/main/kotlin/org/cobalt/command/Command.kt index 1db98b82..dbe09118 100644 --- a/src/main/kotlin/org/cobalt/command/Command.kt +++ b/src/main/kotlin/org/cobalt/command/Command.kt @@ -1,9 +1,15 @@ package org.cobalt.command -import com.mojang.brigadier.arguments.* +import com.mojang.brigadier.arguments.BoolArgumentType +import com.mojang.brigadier.arguments.DoubleArgumentType +import com.mojang.brigadier.arguments.FloatArgumentType +import com.mojang.brigadier.arguments.IntegerArgumentType +import com.mojang.brigadier.arguments.StringArgumentType import com.mojang.brigadier.builder.LiteralArgumentBuilder import com.mojang.brigadier.builder.RequiredArgumentBuilder +import com.mojang.brigadier.context.CommandContext import kotlin.reflect.KFunction +import kotlin.reflect.KParameter import kotlin.reflect.full.declaredFunctions import kotlin.reflect.full.findAnnotation import kotlin.reflect.jvm.isAccessible @@ -11,91 +17,148 @@ import net.minecraft.client.multiplayer.ClientSuggestionProvider import org.cobalt.command.annotation.DefaultHandler import org.cobalt.command.annotation.SubCommand +/** + * Base class for chat commands with option for subcommands. + * + * @property name primary command name + * @property aliases alternate command names + */ abstract class Command(val name: String, val aliases: List = emptyList()) { - fun build(): List> { + internal fun build(): List> { val mainRoot = LiteralArgumentBuilder.literal(name) - val functions = this::class.declaredFunctions - for (function in functions) { + registerFunctions(mainRoot) + + return listOf(mainRoot) + buildAliases(mainRoot) + } + + private fun registerFunctions(mainRoot: LiteralArgumentBuilder) { + for (function in this::class.declaredFunctions) { function.isAccessible = true + if (function.findAnnotation() != null) { mainRoot.executes { function.call(this@Command) - 1 + return@executes 1 } continue } + if (function.findAnnotation() != null) { mainRoot.then(buildSubCommand(function)) } } + } - val aliasRoots = aliases.filter { it.isNotBlank() }.map { alias -> + private fun buildAliases( + mainRoot: LiteralArgumentBuilder, + ): List> { + return aliases.filter { it.isNotBlank() }.map { alias -> val aliasRoot = LiteralArgumentBuilder.literal(alias) mainRoot.arguments.forEach { child -> aliasRoot.then(child) } mainRoot.command?.let { aliasRoot.executes(it) } aliasRoot } - - return listOf(mainRoot) + aliasRoots } - private fun buildSubCommand(function: KFunction<*>): LiteralArgumentBuilder { val literal = LiteralArgumentBuilder.literal(function.name) - val params = function.parameters.drop(1) - if (params.isEmpty()) { - literal.executes { - function.call(this) - return@executes 1 - } + val parameters = function.parameters + val valueParams = parameters.filter { it.kind == KParameter.Kind.VALUE } + + return if (valueParams.isEmpty()) buildNoArgSubCommand(literal, function) + else buildArgSubCommand(literal, function, parameters, valueParams) + } - return literal + private fun buildNoArgSubCommand( + literal: LiteralArgumentBuilder, + function: KFunction<*>, + ): LiteralArgumentBuilder { + literal.executes { + function.call(this@Command) + return@executes 1 } - val arguments = params.mapIndexed { index, param -> + return literal + } + + private fun buildArgSubCommand( + literal: LiteralArgumentBuilder, + function: KFunction<*>, + parameters: List, + valueParams: List, + ): LiteralArgumentBuilder { + val arguments = valueParams.mapIndexed { index, param -> val name = param.name ?: "argument$index" createArgument(name, param.type.classifier) } arguments.last().executes { ctx -> - val mappedArgs = params.mapIndexed { index, param -> - val argumentName = param.name ?: "argument$index" - when (param.type.classifier) { - Double::class -> DoubleArgumentType.getDouble(ctx, argumentName) - Int::class -> IntegerArgumentType.getInteger(ctx, argumentName) - String::class -> StringArgumentType.getString(ctx, argumentName) - Boolean::class -> BoolArgumentType.getBool(ctx, argumentName) - Float::class -> FloatArgumentType.getFloat(ctx, argumentName) - else -> error("Unsupported type: ${param.type}") - } - } - - function.call(this, *mappedArgs.toTypedArray()) + executeFunctionWithContext(function, parameters, valueParams, ctx) return@executes 1 } - val argumentTree = arguments.reduceRight { arg, acc -> - arg.then(acc) - } + val argumentTree = buildArgumentTree(arguments) return literal.then(argumentTree) } + private fun buildArgumentTree( + arguments: List>, + ): RequiredArgumentBuilder { + return arguments.reduceRight { arg, acc -> arg.then(acc) } + } + + private fun executeFunctionWithContext( + function: KFunction<*>, + parameters: List, + valueParams: List, + ctx: CommandContext, + ) { + val mappedValues = valueParams.mapIndexed { index, param -> + val argumentName = param.name ?: "argument$index" + valueExtractors[param.type.classifier]?.invoke(ctx, argumentName) + ?: error("Unsupported type: ${param.type}") + } + + val argsMap = mutableMapOf() + + val instanceParam = parameters.firstOrNull { it.kind == KParameter.Kind.INSTANCE } + if (instanceParam != null) argsMap[instanceParam] = this@Command + + for (i in valueParams.indices) { + argsMap[valueParams[i]] = mappedValues[i] + } + + function.callBy(argsMap) + } + private fun createArgument( name: String, type: Any?, ): RequiredArgumentBuilder { - return when (type) { - Double::class -> RequiredArgumentBuilder.argument(name, DoubleArgumentType.doubleArg()) - Int::class -> RequiredArgumentBuilder.argument(name, IntegerArgumentType.integer()) - String::class -> RequiredArgumentBuilder.argument(name, StringArgumentType.word()) - Boolean::class -> RequiredArgumentBuilder.argument(name, BoolArgumentType.bool()) - Float::class -> RequiredArgumentBuilder.argument(name, FloatArgumentType.floatArg()) - else -> throw IllegalArgumentException("Unsupported parameter type: $type") - } + val argType = argumentTypeSuppliers[type]?.invoke() + ?: throw IllegalArgumentException("Unsupported parameter type: $type") + + return RequiredArgumentBuilder.argument(name, argType) } + private val argumentTypeSuppliers: Map com.mojang.brigadier.arguments.ArgumentType<*>> = mapOf( + Double::class to { DoubleArgumentType.doubleArg() }, + Int::class to { IntegerArgumentType.integer() }, + String::class to { StringArgumentType.word() }, + Boolean::class to { BoolArgumentType.bool() }, + Float::class to { FloatArgumentType.floatArg() }, + ) + + private val valueExtractors: Map, String) -> Any?> = mapOf( + Double::class to { c, n -> DoubleArgumentType.getDouble(c, n) }, + Int::class to { c, n -> IntegerArgumentType.getInteger(c, n) }, + String::class to { c, n -> StringArgumentType.getString(c, n) }, + Boolean::class to { c, n -> BoolArgumentType.getBool(c, n) }, + Float::class to { c, n -> FloatArgumentType.getFloat(c, n) }, + ) + } diff --git a/src/main/kotlin/org/cobalt/command/CommandManager.kt b/src/main/kotlin/org/cobalt/command/CommandManager.kt index 575673f2..e595c8c0 100644 --- a/src/main/kotlin/org/cobalt/command/CommandManager.kt +++ b/src/main/kotlin/org/cobalt/command/CommandManager.kt @@ -1,36 +1,68 @@ package org.cobalt.command import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.exceptions.CommandSyntaxException import net.minecraft.ChatFormatting import net.minecraft.client.multiplayer.ClientSuggestionProvider import org.cobalt.Cobalt.minecraft +import org.cobalt.event.EventBus +import org.cobalt.event.annotation.SubscribeEvent +import org.cobalt.event.impl.ChatSendEvent import org.cobalt.util.ChatUtils -import org.slf4j.LoggerFactory +/** + * Manages the registration of custom commands. + */ object CommandManager { - private val logger = LoggerFactory.getLogger(this::class.java) + /** + * Command dispatcher used for registering and executing custom commands. + */ @JvmStatic - val dispatcher = CommandDispatcher() + internal val dispatcher = CommandDispatcher() - @JvmStatic - val prefix: Char = '.' + /** + * Prefix for custom commands. + */ + internal const val PREFIX: Char = '.' + + init { + EventBus.register(this) + } + /** + * Registers a command with the dispatcher. + * + * @param command the command instance to register + */ @JvmStatic fun register(command: Command) { command.build().forEach { dispatcher.register(it) } } - @JvmStatic - fun handleCommandExecution(content: String) { + @Suppress("UndocumentedPublicFunction") + @SubscribeEvent + fun handleCommandExecution(@Suppress("UnusedParameter") event: ChatSendEvent) { + val content = event.message + + if (!content.startsWith(PREFIX)) { + return + } + val player = minecraft.player ?: return - val commandLine = content.removePrefix(prefix.toString()).trim() - if (commandLine.isEmpty()) return + val commandLine = content.removePrefix(PREFIX.toString()).trim() + + if (commandLine.isEmpty()) { + return + } + try { dispatcher.execute(commandLine, player.connection.suggestionsProvider) - } catch (exception: Exception) { - logger.error("Error while executing command: $commandLine", exception) - ChatUtils.sendMessage("${ChatFormatting.RED}Something went wrong when executing the command") + } catch (exception: CommandSyntaxException) { + ChatUtils.sendSystemMessage("${ChatFormatting.RED}${exception.message}") } + + event.setCancelled(true) } + } diff --git a/src/main/kotlin/org/cobalt/command/annotation/DefaultHandler.kt b/src/main/kotlin/org/cobalt/command/annotation/DefaultHandler.kt index dc080f80..31170244 100644 --- a/src/main/kotlin/org/cobalt/command/annotation/DefaultHandler.kt +++ b/src/main/kotlin/org/cobalt/command/annotation/DefaultHandler.kt @@ -1,5 +1,8 @@ package org.cobalt.command.annotation +/** + * Marks a function as the default command handler. + */ @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) annotation class DefaultHandler diff --git a/src/main/kotlin/org/cobalt/command/annotation/SubCommand.kt b/src/main/kotlin/org/cobalt/command/annotation/SubCommand.kt index 9339d0d9..4f2eab38 100644 --- a/src/main/kotlin/org/cobalt/command/annotation/SubCommand.kt +++ b/src/main/kotlin/org/cobalt/command/annotation/SubCommand.kt @@ -1,5 +1,10 @@ package org.cobalt.command.annotation +/** + * Marks a function as a sub-command. + * + * @param name optional label that defaults to the function name + */ @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) annotation class SubCommand(val name: String = "") diff --git a/src/main/kotlin/org/cobalt/command/impl/MainCommand.kt b/src/main/kotlin/org/cobalt/command/impl/MainCommand.kt index 937790a4..a9f91c3c 100644 --- a/src/main/kotlin/org/cobalt/command/impl/MainCommand.kt +++ b/src/main/kotlin/org/cobalt/command/impl/MainCommand.kt @@ -7,32 +7,23 @@ import org.cobalt.command.annotation.SubCommand import org.cobalt.ui.screen.ConfigScreen import org.cobalt.ui.screen.HudEditorScreen import org.cobalt.util.helper.TickScheduler -import org.cobalt.util.rotation.DefaultRotations -import org.cobalt.util.rotation.RotationManager internal object MainCommand : Command(name = "cobalt", aliases = listOf("cb")) { + private const val DELAY_TICKS = 1L + @DefaultHandler fun main() { - TickScheduler.schedule(1) { + TickScheduler.schedule(DELAY_TICKS) { minecraft.setScreen(ConfigScreen) } } @SubCommand fun hud() { - TickScheduler.schedule(1) { + TickScheduler.schedule(DELAY_TICKS) { minecraft.setScreen(HudEditorScreen) } } - @SubCommand - fun rotate(yaw: Double, pitch: Double) { - RotationManager.setActiveRotation( - DefaultRotations, - yaw = yaw, - pitch = pitch - ) - } - } diff --git a/src/main/kotlin/org/cobalt/dsl/Render.kt b/src/main/kotlin/org/cobalt/dsl/Render.kt new file mode 100644 index 00000000..0fb626b1 --- /dev/null +++ b/src/main/kotlin/org/cobalt/dsl/Render.kt @@ -0,0 +1,65 @@ +package org.cobalt.dsl + +import java.awt.Color +import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext +import net.minecraft.core.BlockPos +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.Vec3 +import org.cobalt.util.RenderUtils + +/** + * Draw a unit cube wireframe and optional translucent fill at the given block position. + * + * @param pos the block position to draw + * @param color the color to use for outline/fill + * @param esp when true uses ESP render type variants + * @param lineWidth line thickness for outlines + */ +fun LevelRenderContext.drawBlockPos(pos: BlockPos, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = + RenderUtils.drawBlockPos(this, pos, color, esp, lineWidth) + +/** + * Draw an outline around the provided entity's bounding box, interpolated for rendering. + * + * @param entity the entity whose bounding box will be outlined + * @param color the outline color + * @param esp when true uses ESP render type variants + * @param lineWidth outline thickness + */ +fun LevelRenderContext.drawEntityOutline(entity: Entity, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = + RenderUtils.drawEntityOutline(this, entity, color, esp, lineWidth) + +/** + * Draw a line from the camera position toward the supplied world-space target point. + * + * @param to world-space target coordinate for the tracer + * @param color tracer color + * @param esp when true uses ESP render type variants + * @param lineWidth tracer thickness + */ +fun LevelRenderContext.drawTracer(to: Vec3, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = + RenderUtils.drawTracer(this, to, color, esp, lineWidth) + +/** + * Draw a colored axis-aligned bounding box (AABB) with optional translucent fill and outline. + * + * @param box the world-space axis-aligned bounding box + * @param color color used for fill/outline + * @param esp when true uses ESP render type variants + * @param lineWidth outline thickness + */ +fun LevelRenderContext.drawBox(box: AABB, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = + RenderUtils.drawBox(this, box, color, esp, lineWidth) + +/** + * Draw a colored line between two world-space points. + * + * @param from start point in world coordinates + * @param to end point in world coordinates + * @param color the color to use for the line + * @param esp whether ESP rendering is enabled + * @param lineWidth thickness of the line + */ +fun LevelRenderContext.drawLine(from: Vec3, to: Vec3, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = + RenderUtils.drawLine(this, from, to, color, esp, lineWidth) diff --git a/src/main/kotlin/org/cobalt/dsl/utils.kt b/src/main/kotlin/org/cobalt/dsl/Utils.kt similarity index 52% rename from src/main/kotlin/org/cobalt/dsl/utils.kt rename to src/main/kotlin/org/cobalt/dsl/Utils.kt index e2d5555e..814a70e2 100644 --- a/src/main/kotlin/org/cobalt/dsl/utils.kt +++ b/src/main/kotlin/org/cobalt/dsl/Utils.kt @@ -2,20 +2,38 @@ package org.cobalt.dsl import org.cobalt.Cobalt.minecraft +/** + * Extracts the red component from an ARGB color integer. + */ inline val Int.red get() = this shr 16 and 0xFF +/** + * Extracts the green component from an ARGB color integer. + */ inline val Int.green get() = this shr 8 and 0xFF +/** + * Extracts the blue component from an ARGB color integer. + */ inline val Int.blue get() = this and 0xFF +/** + * Extracts the alpha component from an ARGB color integer. + */ inline val Int.alpha get() = this shr 24 and 0xFF +/** + * The current X position of the mouse cursor. + */ inline val mouseX: Float get() = minecraft.mouseHandler.xpos().toFloat() +/** + * The current Y position of the mouse cursor. + */ inline val mouseY: Float get() = minecraft.mouseHandler.ypos().toFloat() diff --git a/src/main/kotlin/org/cobalt/dsl/render.kt b/src/main/kotlin/org/cobalt/dsl/render.kt deleted file mode 100644 index c90290a9..00000000 --- a/src/main/kotlin/org/cobalt/dsl/render.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.cobalt.dsl - -import java.awt.Color -import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext -import net.minecraft.core.BlockPos -import net.minecraft.world.entity.Entity -import net.minecraft.world.phys.AABB -import net.minecraft.world.phys.Vec3 -import org.cobalt.util.RenderUtils - -fun LevelRenderContext.drawBlockPos(pos: BlockPos, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = - RenderUtils.drawBlockPos(this, pos, color, esp, lineWidth) - -fun LevelRenderContext.drawEntityOutline(entity: Entity, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = - RenderUtils.drawEntityOutline(this, entity, color, esp, lineWidth) - -fun LevelRenderContext.drawTracer(to: Vec3, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = - RenderUtils.drawTracer(this, to, color, esp, lineWidth) - -fun LevelRenderContext.drawBox(box: AABB, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = - RenderUtils.drawBox(this, box, color, esp, lineWidth) - -fun LevelRenderContext.drawLine(from: Vec3, to: Vec3, color: Color, esp: Boolean = false, lineWidth: Float = 1f) = - RenderUtils.drawLine(this, from, to, color, esp, lineWidth) diff --git a/src/main/kotlin/org/cobalt/event/Event.kt b/src/main/kotlin/org/cobalt/event/Event.kt index 8fa132b8..dfc64636 100644 --- a/src/main/kotlin/org/cobalt/event/Event.kt +++ b/src/main/kotlin/org/cobalt/event/Event.kt @@ -1,32 +1,61 @@ package org.cobalt.event +/** + * Base class for all custom Cobalt events. + */ abstract class Event { + /** + * Base class for events that can be canceled. + */ abstract class Cancellable : Event() { private var cancelled = false + /** + * Returns whether this event has been canceled. + * + * @return true if the event is canceled, false otherwise + */ fun isCancelled(): Boolean { return cancelled } + /** + * Sets the canceled state of this event. + * + * @param cancelled whether the event should be canceled + */ fun setCancelled(cancelled: Boolean) { this.cancelled = cancelled } } + /** + * Priority levels used to determine event listener execution order. + */ enum class Priority { + /** Highest delivery priority; handlers with this priority run before others. */ HIGHEST, + + /** High delivery priority; runs after HIGHEST but before MEDIUM. */ HIGH, + + /** Default delivery priority for handlers. */ MEDIUM, + + /** Low delivery priority; runs after MEDIUM. */ LOW, + + /** Lowest delivery priority; runs last. */ LOWEST; - fun weight(): Int { - return ordinal - } + /** + * Numeric weight used for sorting priorities. + */ + fun weight(): Int = ordinal } diff --git a/src/main/kotlin/org/cobalt/event/EventBus.kt b/src/main/kotlin/org/cobalt/event/EventBus.kt index 0e25cd37..68fa6512 100644 --- a/src/main/kotlin/org/cobalt/event/EventBus.kt +++ b/src/main/kotlin/org/cobalt/event/EventBus.kt @@ -1,11 +1,16 @@ package org.cobalt.event -import org.cobalt.event.annotation.SubscribeEvent import java.lang.invoke.MethodHandles +import java.lang.reflect.Method import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import org.cobalt.event.annotation.SubscribeEvent import org.slf4j.LoggerFactory +/** + * Central event bus responsible for registering, unregistering, + * and dispatching events to subscribed listeners. + */ object EventBus { private data class Handler( @@ -14,95 +19,56 @@ object EventBus { val priority: Event.Priority, val ignoreCancelled: Boolean, val once: Boolean, - val invoker: (Event) -> Unit + val invoker: (Event) -> Unit, ) private val handlers = CopyOnWriteArrayList() private val cache = ConcurrentHashMap, Array>() private val logger = LoggerFactory.getLogger(this::class.java) + /** + * Registers all methods annotated with [SubscribeEvent] from the given listener instance. + * + * @param listener the object containing event subscriber methods + */ @JvmStatic fun register(listener: Any) { - if (handlers.any { it.listener === listener }) { - return - } + if (handlers.any { it.listener === listener }) return - listener.javaClass.declaredMethods.forEach { method -> - val annotation = method.getAnnotation(SubscribeEvent::class.java) - ?: return@forEach - - val params = method.parameterTypes - - if (params.size != 1 || !Event::class.java.isAssignableFrom(params[0])) { - return@forEach - } - - if (!method.trySetAccessible()) { - logger.error( - "EventBus: could not access method ${listener.javaClass.name}#${method.name}, skipping" - ) - return@forEach - } - - val eventType = params[0] - val lookup = MethodHandles.privateLookupIn(listener.javaClass, MethodHandles.lookup()) - val handle = lookup.unreflect(method).bindTo(listener) - - val invoker: (Event) -> Unit = { event -> - handle.invoke(event) - } - - handlers.add( - Handler( - listener = listener, - eventType = eventType, - priority = annotation.priority, - ignoreCancelled = annotation.ignoreCancelled, - once = annotation.once, - invoker = invoker - ) - ) - } + val toAdd = createHandlersForListener(listener) + + if (toAdd.isNotEmpty()) handlers.addAll(toAdd) cache.clear() } + /** + * Unregisters all event handlers associated with the given listener instance. + * + * @param listener the listener whose event handlers should be removed + */ @JvmStatic fun unregister(listener: Any) { handlers.removeIf { it.listener === listener } cache.clear() } + /** + * Posts an event to all registered listeners. + * + * Event handlers are filtered by type, priority, and cancellation rules. + * + * @param event the event to dispatch + * @return the same event instance after processing + */ @JvmStatic fun post(event: Event): Event { val eventClass = event.javaClass - val matched = cache.computeIfAbsent(eventClass) { - handlers - .filter { it.eventType.isAssignableFrom(eventClass) } - .sortedBy { it.priority.ordinal } - .toTypedArray() - } + val matched = cache.computeIfAbsent(eventClass) { computeMatchedHandlers(eventClass) } - var toRemove: MutableList? = null - - for (handler in matched) { - if ( - event is Event.Cancellable && - event.isCancelled() && - !handler.ignoreCancelled - ) { - continue - } - - handler.invoker(event) - - if (handler.once) { - if (toRemove == null) toRemove = mutableListOf() - toRemove.add(handler) - } - } + val toRemove = processMatchedHandlers(matched, event) - if (toRemove != null) { + if (toRemove.isNotEmpty()) { handlers.removeAll(toRemove.toSet()) cache.clear() } @@ -110,4 +76,70 @@ object EventBus { return event } + private fun createHandlersForListener(listener: Any): List { + val result = mutableListOf() + listener.javaClass.declaredMethods.forEach { method -> + createHandlerFromMethod(listener, method)?.let { result.add(it) } + } + return result + } + + private fun createHandlerFromMethod(listener: Any, method: Method): Handler? { + val annotation = method.getAnnotation(SubscribeEvent::class.java) + val params = method.parameterTypes + + if (annotation == null || params.size != 1 || !Event::class.java.isAssignableFrom(params[0])) return null + + if (!method.trySetAccessible()) { + logger.error("EventBus: could not access method ${listener.javaClass.name}#${method.name}, skipping") + return null + } + + val eventType = params.first() + return buildHandler(listener, method, annotation, eventType) + } + + private fun buildHandler(listener: Any, method: Method, annotation: SubscribeEvent, eventType: Class<*>): Handler { + val lookup = MethodHandles.privateLookupIn(listener.javaClass, MethodHandles.lookup()) + val handle = lookup.unreflect(method).bindTo(listener) + + val invoker: (Event) -> Unit = { event -> handle.invoke(event) } + + return Handler( + listener = listener, + eventType = eventType, + priority = annotation.priority, + ignoreCancelled = annotation.ignoreCancelled, + once = annotation.once, + invoker = invoker + ) + } + + private fun processMatchedHandlers(matched: Array, event: Event): MutableList { + val toRemove = mutableListOf() + + for (handler in matched) { + if (shouldSkipHandler(handler, event)) continue + invokeHandler(handler, event, toRemove) + } + + return toRemove + } + + private fun shouldSkipHandler(handler: Handler, event: Event): Boolean { + return (event is Event.Cancellable && event.isCancelled() && !handler.ignoreCancelled) + } + + private fun invokeHandler(handler: Handler, event: Event, toRemove: MutableList) { + handler.invoker(event) + if (handler.once) toRemove.add(handler) + } + + private fun computeMatchedHandlers(eventClass: Class<*>): Array { + return handlers + .filter { it.eventType.isAssignableFrom(eventClass) } + .sortedBy { it.priority.ordinal } + .toTypedArray() + } + } diff --git a/src/main/kotlin/org/cobalt/event/annotation/SubscribeEvent.kt b/src/main/kotlin/org/cobalt/event/annotation/SubscribeEvent.kt index 1796345d..ab34a481 100644 --- a/src/main/kotlin/org/cobalt/event/annotation/SubscribeEvent.kt +++ b/src/main/kotlin/org/cobalt/event/annotation/SubscribeEvent.kt @@ -2,10 +2,17 @@ package org.cobalt.event.annotation import org.cobalt.event.Event +/** + * Marks a function as an event subscriber. + * + * @param ignoreCancelled whether to receive canceled events + * @param priority subscriber execution priority + * @param once if true, subscriber is removed after first call + */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class SubscribeEvent( val ignoreCancelled: Boolean = false, val priority: Event.Priority = Event.Priority.MEDIUM, - val once: Boolean = false + val once: Boolean = false, ) diff --git a/src/main/kotlin/org/cobalt/event/impl/ChatSendEvent.kt b/src/main/kotlin/org/cobalt/event/impl/ChatSendEvent.kt index 20b7716e..db13fdd8 100644 --- a/src/main/kotlin/org/cobalt/event/impl/ChatSendEvent.kt +++ b/src/main/kotlin/org/cobalt/event/impl/ChatSendEvent.kt @@ -2,4 +2,12 @@ package org.cobalt.event.impl import org.cobalt.event.Event +/** + * Custom event fired when the player sends a chat message. + * + * This event extends [Event.Cancellable] and can be canceled to prevent + * the message from being sent. + * + * @property message the message being sent + */ class ChatSendEvent(val message: String) : Event.Cancellable() diff --git a/src/main/kotlin/org/cobalt/event/impl/PacketEvent.kt b/src/main/kotlin/org/cobalt/event/impl/PacketEvent.kt index 496a9885..566ddc44 100644 --- a/src/main/kotlin/org/cobalt/event/impl/PacketEvent.kt +++ b/src/main/kotlin/org/cobalt/event/impl/PacketEvent.kt @@ -3,11 +3,33 @@ package org.cobalt.event.impl import net.minecraft.network.protocol.Packet import org.cobalt.event.Event +/** + * Base type for all packet-related events. + * + * @property packet the packet being sent or received + */ abstract class PacketEvent( val packet: Packet<*>, ) : Event.Cancellable() { + /** + * Custom event fired when a packet is sent to the server. + * + * This event is cancellable via [Event.Cancellable], which prevents the + * packet from being sent. + * + * @property packet the packet being sent + */ class Send(packet: Packet<*>) : PacketEvent(packet) + + /** + * Custom event fired when a packet is received from the server. + * + * This event is cancellable via [Event.Cancellable], which prevents the + * packet from being processed. + * + * @property packet the packet being received + */ class Receive(packet: Packet<*>) : PacketEvent(packet) } diff --git a/src/main/kotlin/org/cobalt/event/impl/SkiaDrawEvent.kt b/src/main/kotlin/org/cobalt/event/impl/SkiaDrawEvent.kt index 783c984d..d6e07d41 100644 --- a/src/main/kotlin/org/cobalt/event/impl/SkiaDrawEvent.kt +++ b/src/main/kotlin/org/cobalt/event/impl/SkiaDrawEvent.kt @@ -5,8 +5,18 @@ import io.github.humbleui.skija.DirectContext import org.cobalt.event.Event import org.cobalt.util.skia.WrappedBackendRenderTarget +/** + * Custom event fired when Skia performs its draw step for the current frame. + * + * This is the callback point where custom rendering can be performed on the + * Skia canvas before it is submitted. + * + * @property context the Skia direct rendering context + * @property renderTarget the backend render target used for rendering + * @property canvas the Skia canvas for drawing operations + */ class SkiaDrawEvent( val context: DirectContext, val renderTarget: WrappedBackendRenderTarget, - val canvas: Canvas + val canvas: Canvas, ) : Event() diff --git a/src/main/kotlin/org/cobalt/event/impl/TickEvent.kt b/src/main/kotlin/org/cobalt/event/impl/TickEvent.kt index 087a48ed..154f58b8 100644 --- a/src/main/kotlin/org/cobalt/event/impl/TickEvent.kt +++ b/src/main/kotlin/org/cobalt/event/impl/TickEvent.kt @@ -2,7 +2,18 @@ package org.cobalt.event.impl import org.cobalt.event.Event +/** + * Base event for client tick lifecycle events. + */ abstract class TickEvent : Event() { + + /** + * Custom event fired at the start of the client tick. + */ class Start : TickEvent() + + /** + * Custom event fired at the end of the client tick. + */ class End : TickEvent() } diff --git a/src/main/kotlin/org/cobalt/event/impl/WorldRenderEvent.kt b/src/main/kotlin/org/cobalt/event/impl/WorldRenderEvent.kt index 10305422..2ca89a98 100644 --- a/src/main/kotlin/org/cobalt/event/impl/WorldRenderEvent.kt +++ b/src/main/kotlin/org/cobalt/event/impl/WorldRenderEvent.kt @@ -1,12 +1,15 @@ package org.cobalt.event.impl import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext -import net.minecraft.client.Camera -import net.minecraft.client.DeltaTracker -import net.minecraft.client.renderer.MultiBufferSource -import net.minecraft.client.renderer.culling.Frustum import org.cobalt.event.Event +/** + * Custom event fired at the end of world rendering. + * + * Intended for use with [org.cobalt.util.RenderUtils] to perform custom world rendering. + * + * @property context the Fabric [LevelRenderContext] for the current render frame + */ class WorldRenderEvent( - val context: LevelRenderContext + val context: LevelRenderContext, ) : Event() diff --git a/src/main/kotlin/org/cobalt/math/Math.kt b/src/main/kotlin/org/cobalt/math/Math.kt new file mode 100644 index 00000000..739b8a06 --- /dev/null +++ b/src/main/kotlin/org/cobalt/math/Math.kt @@ -0,0 +1,23 @@ +package org.cobalt.math + +/** + * Simple 2D vector + * + * @property x x property + * @property y y property + */ +data class Vec2f( + val x: Float, + val y: Float, +) + +/** + * Dimensions wrapper + * + * @property width width property + * @property height height property + */ +data class Dimensions( + val width: Float, + val height: Float, +) diff --git a/src/main/kotlin/org/cobalt/module/Module.kt b/src/main/kotlin/org/cobalt/module/Module.kt index 33ea5b44..a6ba69e4 100644 --- a/src/main/kotlin/org/cobalt/module/Module.kt +++ b/src/main/kotlin/org/cobalt/module/Module.kt @@ -1,20 +1,62 @@ package org.cobalt.module +/** + * Base class for all client modules/features. + * + * A module represents a toggleable feature within the client. + * + * @property name the module name + * @property category the category used to group modules in the UI + * + * @see RenderableModule for modules with rendering capabilities + */ abstract class Module( val name: String, val category: ModuleCategory, ) { - var enabled: Boolean = false + private var enabled: Boolean = false + /** + * Called when the module is registered in the module system. + */ open fun onRegistration() {} + /** + * Returns whether this module is currently enabled. + * + * @return true if the module is enabled, false otherwise + */ + fun isEnabled(): Boolean = enabled + + /** + * Sets the enabled state of this module. + * + * @param enabled whether the module should be enabled or disabled + */ + fun setEnabled(enabled: Boolean) { + this.enabled = enabled + } + + /** + * Returns whether this module supports rendering capabilities. + * + * @return true if this module is a [RenderableModule], false otherwise + */ fun isRenderable(): Boolean { return this is RenderableModule } } +/** + * Module variant that exposes screen-space rendering hooks and layout properties. + * + * Extends [Module] with rendering capabilities. + * + * @property xPos UI X position for rendering the module. + * @property yPos UI Y position for rendering the module. + */ abstract class RenderableModule( name: String, category: ModuleCategory, @@ -22,31 +64,74 @@ abstract class RenderableModule( var yPos: Float, ) : Module(name, category) { + /** + * UI scale factor for rendering the module. + */ var scale: Float = 1.0f + /** + * Returns the rendered width of this module in screen space. + * + * @return the module width in pixels + */ abstract fun getWidth(): Float + + /** + * Returns the rendered height of this module in screen space. + * + * @return the module height in pixels + */ abstract fun getHeight(): Float + + /** + * Renders this module to the screen. + */ abstract fun renderModule() } -class ModuleCategory private constructor(val displayName: String) { +/** + * Represents a grouping category for modules used in the UI. + * + * @property displayName the name shown in the UI + */ +class ModuleCategory private constructor( + val displayName: String, +) { + companion object { private val entries = mutableMapOf() + /** + * Predefined category for rendering-related modules. + */ @JvmField val RENDER = register(displayName = "Render") + /** + * Registers a new module category or returns an existing one. + * + * Categories are stored case-insensitively. + * + * @param displayName the name of the category + * @return the existing or newly created [ModuleCategory] + */ fun register(displayName: String): ModuleCategory { return entries.getOrPut(displayName.lowercase()) { ModuleCategory(displayName) } } + /** + * Returns all registered module categories. + * + * @return a collection of all [ModuleCategory] instances + */ fun getCategories(): Collection { return entries.values } } + } diff --git a/src/main/kotlin/org/cobalt/module/ModuleManager.kt b/src/main/kotlin/org/cobalt/module/ModuleManager.kt index 5c6bd63f..81dbe89d 100644 --- a/src/main/kotlin/org/cobalt/module/ModuleManager.kt +++ b/src/main/kotlin/org/cobalt/module/ModuleManager.kt @@ -4,9 +4,14 @@ import org.cobalt.Cobalt.minecraft import org.cobalt.event.EventBus import org.cobalt.event.annotation.SubscribeEvent import org.cobalt.event.impl.SkiaDrawEvent +import org.cobalt.math.Vec2f import org.cobalt.module.impl.render.PerformanceHUD -import org.cobalt.util.skia.SkiaRenderer +import org.cobalt.util.WindowUtils +import org.cobalt.util.skia.SkiaTransforms +/** + * Central registry and lifecycle manager for all client modules. + */ object ModuleManager { private val modules = mutableSetOf() @@ -15,7 +20,7 @@ object ModuleManager { EventBus.register(this) } - fun registerModules() { + internal fun registerModules() { val builtIn = arrayOf( PerformanceHUD ) @@ -25,6 +30,11 @@ object ModuleManager { } } + /** + * Adds a module to the registry. + * + * @throws IllegalStateException if a module with the same name is already registered + */ fun addModule(module: Module) { if (!modules.add(module)) { error("'${module.name}' is already registered") @@ -33,43 +43,67 @@ object ModuleManager { module.onRegistration() } + /** + * Removes a module from the registry. + * + * @param module the module to remove + * @return true if the module was removed, false if it was not registered + */ + fun removeModule(module: Module): Boolean { + return modules.remove(module) + } + + /** + * Returns a module by name (case-insensitive). + * + * @param moduleName the name of the module + * @return the matching module, or null if not found + */ fun getModule(moduleName: String): Module? { return modules.find { module -> module.name.equals(moduleName, true) } } + /** + * Returns all registered modules. + * + * @return a set of all modules in the registry + */ fun getModules(): Set { return modules } + @Suppress("UndocumentedPublicFunction") @SubscribeEvent - fun drawRenderableModules(event: SkiaDrawEvent) { + fun drawRenderableModules(@Suppress("UnusedParameter") event: SkiaDrawEvent) { if (minecraft.level == null) { return } - val windowScale = SkiaRenderer.getWindowScale() + val windowScale = WindowUtils.getWindowScale() modules - .filter { module -> module.enabled && module.isRenderable() } + .filter { module -> module.isEnabled() && module.isRenderable() } .forEach { module -> val renderable = module as RenderableModule - SkiaRenderer.save() + SkiaTransforms.save() val originX = renderable.xPos val originY = renderable.yPos val moduleScale = renderable.scale * windowScale - SkiaRenderer.translate(originX, originY) - SkiaRenderer.scale(moduleScale, moduleScale) - SkiaRenderer.translate(-originX, -originY) + SkiaTransforms.translate(Vec2f(originX, originY)) + SkiaTransforms.scale(Vec2f(moduleScale, moduleScale)) + SkiaTransforms.translate(Vec2f(-originX, -originY)) renderable.renderModule() - SkiaRenderer.restore() + SkiaTransforms.restore() } } } + + diff --git a/src/main/kotlin/org/cobalt/module/impl/render/PerformanceHUD.kt b/src/main/kotlin/org/cobalt/module/impl/render/PerformanceHUD.kt index f317f90b..108eda21 100644 --- a/src/main/kotlin/org/cobalt/module/impl/render/PerformanceHUD.kt +++ b/src/main/kotlin/org/cobalt/module/impl/render/PerformanceHUD.kt @@ -2,48 +2,103 @@ package org.cobalt.module.impl.render import kotlin.math.roundToInt import org.cobalt.Cobalt.minecraft +import org.cobalt.math.Dimensions +import org.cobalt.math.Vec2f import org.cobalt.module.ModuleCategory import org.cobalt.module.RenderableModule import org.cobalt.ui.ColorPalette import org.cobalt.util.ServerUtils -import org.cobalt.util.skia.SkiaRenderer +import org.cobalt.util.skia.SkiaShapes +import org.cobalt.util.skia.SkiaText -object PerformanceHUD : RenderableModule( +private const val DEFAULT_OFFSET = 5.0f + +internal object PerformanceHUD : RenderableModule( name = "Performance HUD", category = ModuleCategory.RENDER, - xPos = 5.0f, - yPos = 5.0f, + xPos = DEFAULT_OFFSET, + yPos = DEFAULT_OFFSET, ) { private const val PADDING = 25f + private const val CORNER_RADIUS = 5f + private const val OUTLINE_THICKNESS = 2f + private const val FONT_SIZE = 16f + private const val TEXT_SPACING = 5f + private const val DIVIDER_HALF_HEIGHT = 10f + private const val PANEL_HEIGHT = 50f + private const val MID_FACTOR = 0.5f + private const val DIVIDER_GAP = PADDING / 2 + TEXT_SPACING override fun renderModule() { val width = getWidth() val height = getHeight() - val centerY = yPos + height / 2 - SkiaRenderer.roundedRect(xPos, yPos, width, height, 5f, ColorPalette.PANEL) - SkiaRenderer.roundedOutline(xPos, yPos, width, height, 5f, ColorPalette.BORDER, 2f) + drawBackground(width, height) + drawStats(height) + } + + private fun drawBackground(width: Float, height: Float) { + SkiaShapes.drawRoundedRect(Vec2f(xPos, yPos), Dimensions(width, height), CORNER_RADIUS, ColorPalette.PANEL) + SkiaShapes.drawRoundedOutline( + Vec2f(xPos, yPos), + Dimensions(width, height), + CORNER_RADIUS, + ColorPalette.BORDER, + OUTLINE_THICKNESS + ) + } + + private fun drawStats(height: Float) { + val centerY = yPos + height / 2 var currentX = xPos + PADDING - val textY = centerY - 16f / 2 + val textY = centerY - FONT_SIZE / 2 for ((index, stat) in getStats().withIndex()) { if (index > 0) { - currentX += PADDING / 2 + 5f + currentX = drawDivider(currentX, height) + } - val midY = yPos + height * 0.5f - SkiaRenderer.line(currentX, currentX, midY - 10f, midY + 10f, ColorPalette.BORDER, 2f) + currentX = drawStatText(stat, currentX, textY) + } + } - currentX += PADDING / 2 + 5f - } + private fun drawDivider(startX: Float, height: Float): Float { + var x = startX + DIVIDER_GAP - SkiaRenderer.text(SkiaRenderer.primaryFont, stat.value, currentX, textY, 16f, ColorPalette.TEXT_PRIMARY) - currentX += SkiaRenderer.textWidth(SkiaRenderer.primaryFont, stat.value, 16f) + 5f + val midY = yPos + height * MID_FACTOR + SkiaShapes.drawLine( + Vec2f(x, midY - DIVIDER_HALF_HEIGHT), + Vec2f(x, midY + DIVIDER_HALF_HEIGHT), + ColorPalette.BORDER, + OUTLINE_THICKNESS + ) - SkiaRenderer.text(SkiaRenderer.primaryFont, stat.unit, currentX, textY, 16f, ColorPalette.TEXT_DISABLED) - currentX += SkiaRenderer.textWidth(SkiaRenderer.primaryFont, stat.unit, 16f) - } + x += DIVIDER_GAP + return x + } + + private fun drawStatText(stat: Stat, startX: Float, textY: Float): Float { + var x = startX + + SkiaText.drawText( + SkiaText.primaryFont, + stat.value, + Vec2f(x, textY), + SkiaText.TextStyle(FONT_SIZE, ColorPalette.TEXT_PRIMARY) + ) + x += SkiaText.getTextWidth(SkiaText.primaryFont, stat.value, FONT_SIZE) + TEXT_SPACING + + SkiaText.drawText( + SkiaText.primaryFont, + stat.unit, + Vec2f(x, textY), + SkiaText.TextStyle(FONT_SIZE, ColorPalette.TEXT_DISABLED) + ) + x += SkiaText.getTextWidth(SkiaText.primaryFont, stat.unit, FONT_SIZE) + + return x } override fun getWidth(): Float { @@ -51,17 +106,17 @@ object PerformanceHUD : RenderableModule( for ((index, stat) in getStats().withIndex()) { if (index > 0) { - width += PADDING / 2 + PADDING / 2 + 10f + width += PADDING + 2 * TEXT_SPACING } - width += SkiaRenderer.textWidth(SkiaRenderer.primaryFont, stat.value, 16f) + 5f - width += SkiaRenderer.textWidth(SkiaRenderer.primaryFont, stat.unit, 16f) + width += SkiaText.getTextWidth(SkiaText.primaryFont, stat.value, FONT_SIZE) + TEXT_SPACING + width += SkiaText.getTextWidth(SkiaText.primaryFont, stat.unit, FONT_SIZE) } return width } - override fun getHeight(): Float = 50f + override fun getHeight(): Float = PANEL_HEIGHT private fun getStats() = listOf( Stat(getFPS(), "FPS"), diff --git a/src/main/kotlin/org/cobalt/ui/ColorPalette.kt b/src/main/kotlin/org/cobalt/ui/ColorPalette.kt index d0e7bd60..d1447f11 100644 --- a/src/main/kotlin/org/cobalt/ui/ColorPalette.kt +++ b/src/main/kotlin/org/cobalt/ui/ColorPalette.kt @@ -2,25 +2,34 @@ package org.cobalt.ui import java.awt.Color -// TODO: bring this over to a theme manager +/** + * Global color definitions used across the UI. + * + * TODO: Bring to a theme manager + */ +@Suppress("UndocumentedPublicProperty") object ColorPalette { + // Backgrounds val BACKGROUND_PRIMARY = Color(18, 18, 18).rgb val BACKGROUND_SECONDARY = Color(24, 24, 24).rgb val PANEL = Color(30, 30, 30).rgb val HOVER = Color(37, 37, 37).rgb val BORDER = Color(42, 42, 42).rgb + // Accent colors val ACCENT_PRIMARY = Color(79, 140, 255).rgb val ACCENT_HOVER = Color(106, 162, 255).rgb val ACCENT_ACTIVE = Color(58, 116, 230).rgb val ACCENT_GLOW = Color(47, 95, 191, 64).rgb + // Text colors val TEXT_PRIMARY = Color(230, 230, 230).rgb val TEXT_SECONDARY = Color(179, 179, 179).rgb val TEXT_MUTED = Color(122, 122, 122).rgb val TEXT_DISABLED = Color(95, 95, 95).rgb + // Semantic states val SUCCESS = Color(63, 191, 127).rgb val WARNING = Color(230, 181, 102).rgb val ERROR = Color(224, 90, 90).rgb diff --git a/src/main/kotlin/org/cobalt/ui/UIComponent.kt b/src/main/kotlin/org/cobalt/ui/UIComponent.kt index 65f8155f..5007f8c2 100644 --- a/src/main/kotlin/org/cobalt/ui/UIComponent.kt +++ b/src/main/kotlin/org/cobalt/ui/UIComponent.kt @@ -1,5 +1,13 @@ package org.cobalt.ui +/** + * Base class for all drawable UI components. + * + * @property xPos X position of the component + * @property yPos Y position of the component + * @property width component width + * @property height component height + */ abstract class UIComponent( var xPos: Float, var yPos: Float, @@ -7,8 +15,18 @@ abstract class UIComponent( open val height: Float = 0.0f, ) { + /** + * Render the component's contents. + */ abstract fun renderComponent() + /** + * Updates the component position. + * + * @param xPos new X coordinate in screen space + * @param yPos new Y coordinate in screen space + * @return this component for chaining + */ fun updateBounds(xPos: Float, yPos: Float): UIComponent { this.xPos = xPos this.yPos = yPos diff --git a/src/main/kotlin/org/cobalt/ui/animation/Animation.kt b/src/main/kotlin/org/cobalt/ui/animation/Animation.kt index 0add9c0c..b0c5f03b 100644 --- a/src/main/kotlin/org/cobalt/ui/animation/Animation.kt +++ b/src/main/kotlin/org/cobalt/ui/animation/Animation.kt @@ -5,14 +5,35 @@ package org.cobalt.ui.animation +/** + * Base class for value animations over a fixed duration. + */ abstract class Animation(private val duration: Long) { - private var startTime: Long = 0L + companion object { + private const val PERCENT_MAX: Float = 100f + private const val MIN_PROGRESS: Float = 0f + private const val MAX_PROGRESS: Float = 1f + private const val ZERO_TIME: Long = 0L + } + + private var startTime: Long = ZERO_TIME private var animating = false private var reversed = false + /** + * Computes the interpolated value between start and end based on progress. + * + * @param start starting value + * @param end ending value + * @param reverse whether interpolation direction is reversed + * @return the interpolated value at the current animation state + */ abstract fun get(start: T, end: T, reverse: Boolean = false): T + /** + * Starts the animation, or reverses its direction if already running. + */ fun start() { val currentTime = System.currentTimeMillis() @@ -23,24 +44,34 @@ abstract class Animation(private val duration: Long) { return } - val percent = ((currentTime - startTime) / duration.toFloat()).coerceIn(0f, 1f) + val percent = ((currentTime - startTime) / duration.toFloat()).coerceIn(MIN_PROGRESS, MAX_PROGRESS) reversed = !reversed - startTime = currentTime - ((1f - percent) * duration).toLong() + startTime = currentTime - ((MAX_PROGRESS - percent) * duration).toLong() return } + /** + * Returns the current animation progress as a percentage. + * + * @return animation progress between 0 and 100 + */ fun getPercent(): Float { - if (!animating) return 100f - val percent = ((System.currentTimeMillis() - startTime) / duration.toFloat() * 100f) + if (!animating) return PERCENT_MAX + val percent = ((System.currentTimeMillis() - startTime) / duration.toFloat() * PERCENT_MAX) - if (percent >= 100f) { + if (percent >= PERCENT_MAX) { animating = false - return 100f + return PERCENT_MAX } - return percent.coerceAtMost(100f) + return percent.coerceAtMost(PERCENT_MAX) } + /** + * Returns whether the animation is currently running. + * + * @return true if the animation is in progress, false otherwise + */ fun isAnimating(): Boolean { return animating } diff --git a/src/main/kotlin/org/cobalt/ui/animation/BounceAnimation.kt b/src/main/kotlin/org/cobalt/ui/animation/BounceAnimation.kt index 43059f74..1cbd70ea 100644 --- a/src/main/kotlin/org/cobalt/ui/animation/BounceAnimation.kt +++ b/src/main/kotlin/org/cobalt/ui/animation/BounceAnimation.kt @@ -2,27 +2,40 @@ package org.cobalt.ui.animation import kotlin.math.pow +/** + * Bounce-style animation that applies an easing function with overshoot. + */ class BounceAnimation(duration: Long) : Animation(duration) { + companion object { + private const val PERCENT_DIVISOR: Float = 100f + private const val FIRST_PHASE_THRESHOLD: Float = 0.3f + private const val SECOND_PHASE_RANGE: Float = 0.7f + private const val OVERSHOOT: Float = 1.05f + private const val OVERSHOOT_DECAY: Float = 0.05f + private const val FIRST_EASE_EXP: Float = 3f + private const val SECOND_EASE_EXP: Float = 2f + } + override fun get(start: Float, end: Float, reverse: Boolean): Float { if (!isAnimating()) return if (reverse) start else end return if (reverse) end + (start - end) * ease() else start + (end - start) * ease() } private fun ease(): Float { - val x = getPercent() / 100f + val x = getPercent() / PERCENT_DIVISOR return when { - x < 0.3f -> { - val t = x / 0.3f - val easeOut = 1f - (1f - t).pow(3) - easeOut * 1.05f + x < FIRST_PHASE_THRESHOLD -> { + val t = x / FIRST_PHASE_THRESHOLD + val easeOut = 1f - (1f - t).pow(FIRST_EASE_EXP) + easeOut * OVERSHOOT } else -> { - val t = (x - 0.3f) / 0.7f - val easeOut = 1f - (1f - t).pow(2) - 1.05f - (0.05f * easeOut) + val t = (x - FIRST_PHASE_THRESHOLD) / SECOND_PHASE_RANGE + val easeOut = 1f - (1f - t).pow(SECOND_EASE_EXP) + OVERSHOOT - (OVERSHOOT_DECAY * easeOut) } } } diff --git a/src/main/kotlin/org/cobalt/ui/notification/Notification.kt b/src/main/kotlin/org/cobalt/ui/notification/Notification.kt index 24a6d287..a74904e4 100644 --- a/src/main/kotlin/org/cobalt/ui/notification/Notification.kt +++ b/src/main/kotlin/org/cobalt/ui/notification/Notification.kt @@ -3,27 +3,53 @@ package org.cobalt.ui.notification import kotlin.time.Duration import org.cobalt.ui.UIComponent +/** + * On-screen notification UI element. + * + * @property title short headline text shown prominently + * @property description body text shown below the title + * @property duration how long the notification remains visible + */ data class Notification( val title: String, val description: String, - val duration: Duration + val duration: Duration, ) : UIComponent( - xPos = 0f, - yPos = 0f, - width = 100f, - height = 100f + xPos = DEFAULT_X, + yPos = DEFAULT_Y, + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT ) { - override fun renderComponent() { - + companion object { + private const val DEFAULT_X: Float = 0f + private const val DEFAULT_Y: Float = 0f + private const val DEFAULT_WIDTH: Float = 100f + private const val DEFAULT_HEIGHT: Float = 100f } + /** + * Renders the notification contents. + */ + override fun renderComponent() { + // TODO: draw the actual notification here.. + } } +/** Types of notifications used to indicate severity or purpose. */ enum class NotificationType { + + /** Indicates a successful operation. */ SUCCESS, + + /** Indicates a warning or non-critical issue. */ WARNING, + + /** Indicates an error or critical problem. */ ERROR, + + /** Informational message without success/error semantics. */ INFO + } diff --git a/src/main/kotlin/org/cobalt/ui/notification/NotificationManager.kt b/src/main/kotlin/org/cobalt/ui/notification/NotificationManager.kt index c6d93917..d6e6ec88 100644 --- a/src/main/kotlin/org/cobalt/ui/notification/NotificationManager.kt +++ b/src/main/kotlin/org/cobalt/ui/notification/NotificationManager.kt @@ -3,8 +3,13 @@ package org.cobalt.ui.notification import org.cobalt.event.EventBus import org.cobalt.event.annotation.SubscribeEvent import org.cobalt.event.impl.SkiaDrawEvent -import org.cobalt.util.skia.SkiaRenderer +import org.cobalt.math.Vec2f +import org.cobalt.util.WindowUtils +import org.cobalt.util.skia.SkiaTransforms +/** + * Central manager for Cobalt notifications. + */ object NotificationManager { private val notificationsList = mutableSetOf() @@ -13,29 +18,37 @@ object NotificationManager { EventBus.register(this) } + /** + * Adds a notification to be rendered. + * + * @param notification the notification instance to display + */ fun pushNotification(notification: Notification) { notificationsList.add(notification) } + @Suppress("UndocumentedPublicFunction") @SubscribeEvent - fun onSkiaDraw(event: SkiaDrawEvent) { - val windowScale = SkiaRenderer.getWindowScale() + fun onSkiaDraw(@Suppress("UnusedParameter") event: SkiaDrawEvent) { + val windowScale = WindowUtils.getWindowScale() notificationsList .forEach { notification -> - SkiaRenderer.save() + SkiaTransforms.save() val originX = notification.xPos val originY = notification.yPos - SkiaRenderer.translate(originX, originY) - SkiaRenderer.scale(windowScale, windowScale) - SkiaRenderer.translate(-originX, -originY) + SkiaTransforms.translate(Vec2f(originX, originY)) + SkiaTransforms.scale(Vec2f(windowScale, windowScale)) + SkiaTransforms.translate(Vec2f(-originX, -originY)) notification.renderComponent() - SkiaRenderer.restore() + SkiaTransforms.restore() } } } + + diff --git a/src/main/kotlin/org/cobalt/ui/screen/ConfigScreen.kt b/src/main/kotlin/org/cobalt/ui/screen/ConfigScreen.kt index de1d56cd..9c257147 100644 --- a/src/main/kotlin/org/cobalt/ui/screen/ConfigScreen.kt +++ b/src/main/kotlin/org/cobalt/ui/screen/ConfigScreen.kt @@ -6,8 +6,9 @@ import net.minecraft.network.chat.Component import org.cobalt.event.EventBus import org.cobalt.event.annotation.SubscribeEvent import org.cobalt.event.impl.SkiaDrawEvent +import org.cobalt.math.Vec2f import org.cobalt.ui.animation.BounceAnimation -import org.cobalt.util.skia.SkiaRenderer +import org.cobalt.util.skia.SkiaTransforms internal object ConfigScreen : Screen(Component.empty()) { @@ -17,8 +18,9 @@ internal object ConfigScreen : Screen(Component.empty()) { EventBus.register(this) } + @Suppress("UndocumentedPublicFunction") @SubscribeEvent - fun onSkiaDraw(event: SkiaDrawEvent) { + fun onSkiaDraw(@Suppress("UnusedParameter") event: SkiaDrawEvent) { if (minecraft.screen != this) { return } @@ -32,16 +34,16 @@ internal object ConfigScreen : Screen(Component.empty()) { val cx = width / 2f val cy = height / 2f - SkiaRenderer.save() - SkiaRenderer.translate(cx, cy) - SkiaRenderer.scale(scale, scale) - SkiaRenderer.translate(-cx, -cy) + SkiaTransforms.save() + SkiaTransforms.translate(Vec2f(cx, cy)) + SkiaTransforms.scale(Vec2f(scale, scale)) + SkiaTransforms.translate(Vec2f(-cx, -cy)) } // TODO: draw the actual UI here.. if (openAnim.isAnimating()) { - SkiaRenderer.restore() + SkiaTransforms.restore() } } @@ -49,7 +51,14 @@ internal object ConfigScreen : Screen(Component.empty()) { openAnim.start() } - override fun extractBlurredBackground(graphics: GuiGraphicsExtractor) {} - override fun extractMenuBackground(graphics: GuiGraphicsExtractor) {} + override fun extractBlurredBackground(graphics: GuiGraphicsExtractor) { + return + } + + override fun extractMenuBackground(graphics: GuiGraphicsExtractor) { + return + } } + + diff --git a/src/main/kotlin/org/cobalt/ui/screen/HudEditorScreen.kt b/src/main/kotlin/org/cobalt/ui/screen/HudEditorScreen.kt index 900ce90c..d5a53735 100644 --- a/src/main/kotlin/org/cobalt/ui/screen/HudEditorScreen.kt +++ b/src/main/kotlin/org/cobalt/ui/screen/HudEditorScreen.kt @@ -6,14 +6,15 @@ import org.cobalt.event.EventBus import org.cobalt.event.annotation.SubscribeEvent import org.cobalt.event.impl.SkiaDrawEvent -object HudEditorScreen : Screen(Component.empty()) { +internal object HudEditorScreen : Screen(Component.empty()) { init { EventBus.register(this) } + @Suppress("UndocumentedPublicFunction") @SubscribeEvent - fun onSkiaDraw(event: SkiaDrawEvent) { + fun onSkiaDraw(@Suppress("UnusedParameter") event: SkiaDrawEvent) { if (minecraft.screen != this) { return } diff --git a/src/main/kotlin/org/cobalt/util/ChatUtils.kt b/src/main/kotlin/org/cobalt/util/ChatUtils.kt index 26835ee4..bc08f5cd 100644 --- a/src/main/kotlin/org/cobalt/util/ChatUtils.kt +++ b/src/main/kotlin/org/cobalt/util/ChatUtils.kt @@ -7,8 +7,13 @@ import org.cobalt.Cobalt import org.cobalt.Cobalt.minecraft import org.slf4j.LoggerFactory +/** + * Utility for sending chat and system messages. + */ object ChatUtils { + private val logger = LoggerFactory.getLogger(this::class.java) + private val defaultPrefix = Component.literal("") .append(Component.literal("[").withStyle(ChatFormatting.DARK_GRAY)) .append(ColorUtils.buildTextGradient(Cobalt.MOD_NAME, 0x4CADD0, 0xB2F9FF)) @@ -19,43 +24,72 @@ object ChatUtils { .append(ColorUtils.buildTextGradient("${Cobalt.MOD_NAME} Debug", 0x369876, 0x71FF9E)) .append(Component.literal("] ").withStyle(ChatFormatting.DARK_GRAY)) - private val logger = LoggerFactory.getLogger(this::class.java) + private var lastDebugMessage: String? = null + /** + * Sends a system message to the local player with optional formatting. + * + * @param message the message content to display + * @param type determines how the message is formatted (prefix, debug, or raw) + */ @JvmStatic - fun sendMessage(message: String, type: MessageType = MessageType.DEFAULT) { + fun sendSystemMessage(message: String, type: MessageType = MessageType.DEFAULT) { val player = minecraft.player if (player == null) { - logger.error("Attempted to send message ($message) but mc.player is null") + logger.error("Attempted to send system message ($message) but mc.player is null") return } val component = when (type) { MessageType.DEFAULT -> defaultPrefix.copy().append(stringToComponent(message)) - MessageType.DEBUG -> debugPrefix.copy().append(stringToComponent(message)) MessageType.RAW -> stringToComponent(message) + MessageType.DEBUG -> { + if (lastDebugMessage == message) { + return + } + + lastDebugMessage = message + debugPrefix.copy().append(stringToComponent(message)) + } } player.sendSystemMessage(component) } + /** + * Converts a plain string into a Minecraft chat component. + * + * @param string the raw text to convert + * @return a mutable chat component representing the input string + */ @JvmStatic fun stringToComponent(string: String): MutableComponent { return Component.literal(string) } + /** + * Sends a chat message as the player. + * + * @param message the message to send in chat + */ @JvmStatic - fun sendChatMessage(message: String) { + fun sendPlayerMessage(message: String) { val player = minecraft.player if (player == null) { - logger.error("Attempted to send message ($message) but mc.player is null") + logger.error("Attempted to send message as player ($message) but mc.player is null") return } player.connection.sendChat(message) } + /** + * Sends a command as the player to the server. + * + * @param command the command string without the leading slash + */ @JvmStatic fun sendCommand(command: String) { val player = minecraft.player @@ -70,8 +104,25 @@ object ChatUtils { } +/** + * Type of message formatting used by [ChatUtils]. + * + * Determines whether a prefix is applied or if the message is sent raw. + */ enum class MessageType { + + /** Default message includes the mod prefix. */ DEFAULT, - DEBUG, - RAW + + /** Raw messages are sent without any prefix. */ + RAW, + + /** + * Debug message includes the debug prefix. + * + * Additionally, duplicate consecutive messages are ignored to prevent spam. + * If the same message is sent twice in a row, it will not be re-displayed. + */ + DEBUG + } diff --git a/src/main/kotlin/org/cobalt/util/ColorUtils.kt b/src/main/kotlin/org/cobalt/util/ColorUtils.kt index 3e115037..0fc47e5e 100644 --- a/src/main/kotlin/org/cobalt/util/ColorUtils.kt +++ b/src/main/kotlin/org/cobalt/util/ColorUtils.kt @@ -10,24 +10,41 @@ import org.cobalt.dsl.blue import org.cobalt.dsl.green import org.cobalt.dsl.red +/** + * Utility functions for color manipulation and text styling. + */ object ColorUtils { + private const val MIN_TEXT_LENGTH = 1 + private const val SHIFT_RED = 16 + private const val SHIFT_GREEN = 8 + + /** + * Creates a gradient-colored text component where each character + * is interpolated between two ARGB colors. + * + * @param text input text + * @param startColor starting ARGB color + * @param endColor ending ARGB color + * @return gradient-colored [MutableComponent] + */ @JvmStatic fun buildTextGradient(text: String, startColor: Int, endColor: Int): MutableComponent { val result = Component.empty() val textLength = text.length - if (textLength <= 1) { + if (textLength <= MIN_TEXT_LENGTH) { return Component.literal(text) .setStyle(Style.EMPTY.withColor(TextColor.fromRgb(startColor))) } for (index in text.indices) { - val ratio = index.toDouble() / (textLength - 1) + val denominator = (textLength - MIN_TEXT_LENGTH).toDouble() + val ratio = index.toDouble() / denominator val red = (startColor.red + ratio * (endColor.red - startColor.red)).roundToInt() val green = (startColor.green + ratio * (endColor.green - startColor.green)).roundToInt() val blue = (startColor.blue + ratio * (endColor.blue - startColor.blue)).roundToInt() - val interpolatedColor = (red shl 16) or (green shl 8) or blue + val interpolatedColor = (red shl SHIFT_RED) or (green shl SHIFT_GREEN) or blue val coloredChar = Component.literal(text[index].toString()) .setStyle(Style.EMPTY.withColor(TextColor.fromRgb(interpolatedColor))) @@ -38,15 +55,39 @@ object ColorUtils { return result } + /** + * Extracts the red channel from an ARGB color value. + * + * @param color the ARGB color integer + * @return the red component (0–255) + */ @JvmStatic fun getRed(color: Int) = color.red + /** + * Extracts the green channel from an ARGB color value. + * + * @param color the ARGB color integer + * @return the green component (0–255) + */ @JvmStatic fun getGreen(color: Int) = color.green + /** + * Extracts the blue channel from an ARGB color value. + * + * @param color the ARGB color integer + * @return the blue component (0–255) + */ @JvmStatic fun getBlue(color: Int) = color.blue + /** + * Extracts the alpha channel from an ARGB color value. + * + * @param color the ARGB color integer + * @return the alpha component (0–255) + */ @JvmStatic fun getAlpha(color: Int) = color.alpha diff --git a/src/main/kotlin/org/cobalt/util/FrustumUtils.kt b/src/main/kotlin/org/cobalt/util/FrustumUtils.kt index 4afafaa7..783494ff 100644 --- a/src/main/kotlin/org/cobalt/util/FrustumUtils.kt +++ b/src/main/kotlin/org/cobalt/util/FrustumUtils.kt @@ -5,13 +5,35 @@ import net.minecraft.world.phys.AABB import org.cobalt.mixin.render.FrustumInvoker import org.joml.FrustumIntersection +/** + * Utility functions for frustum-based visibility checks. + */ object FrustumUtils { + /** + * Checks whether an axis-aligned bounding box is visible within the given frustum. + * + * @param frustum the view frustum used for visibility testing + * @param box the bounding box to test + * @return true if the box is inside or intersects the frustum, false otherwise + */ @JvmStatic fun isVisible(frustum: Frustum, box: AABB): Boolean { return isVisible(frustum, box.minX, box.minY, box.minZ, box.maxX, box.maxY, box.maxZ) } + /** + * Checks whether an axis-aligned bounding box is visible within the given frustum. + * + * @param frustum the view frustum used for visibility testing + * @param minX minimum X coordinate of the box + * @param minY minimum Y coordinate of the box + * @param minZ minimum Z coordinate of the box + * @param maxX maximum X coordinate of the box + * @param maxY maximum Y coordinate of the box + * @param maxZ maximum Z coordinate of the box + * @return true if the box is inside or intersects the frustum, false otherwise + */ @JvmStatic fun isVisible( frustum: Frustum, diff --git a/src/main/kotlin/org/cobalt/util/MouseUtils.kt b/src/main/kotlin/org/cobalt/util/MouseUtils.kt deleted file mode 100644 index 4e642b8c..00000000 --- a/src/main/kotlin/org/cobalt/util/MouseUtils.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.cobalt.util - -import org.cobalt.dsl.mouseX -import org.cobalt.dsl.mouseY - -object MouseUtils { - - @JvmStatic - fun getMouseX(): Float { - return mouseX - } - - @JvmStatic - fun getMouseY(): Float { - return mouseY - } - -} diff --git a/src/main/kotlin/org/cobalt/util/RenderUtils.kt b/src/main/kotlin/org/cobalt/util/RenderUtils.kt index f0a583bb..2debee07 100644 --- a/src/main/kotlin/org/cobalt/util/RenderUtils.kt +++ b/src/main/kotlin/org/cobalt/util/RenderUtils.kt @@ -1,47 +1,72 @@ package org.cobalt.util +import com.mojang.blaze3d.vertex.PoseStack +import com.mojang.blaze3d.vertex.VertexConsumer import java.awt.Color import kotlin.math.max import kotlin.math.min import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext -import net.minecraft.client.Minecraft import net.minecraft.core.BlockPos import net.minecraft.world.entity.Entity import net.minecraft.world.phys.AABB import net.minecraft.world.phys.Vec3 -import org.cobalt.Cobalt.minecraft +import org.cobalt.Cobalt import org.cobalt.util.helper.Layers +import org.joml.Matrix4f +/** + * Utility rendering helpers for drawing boxes, outlines, tracers and lines in world space. + */ object RenderUtils { + private const val ALPHA = 100 + + /** + * Draw a unit cube wireframe and optional translucent fill at the given block position. + * + * @param context the level render context to draw with + * @param pos the block position to draw + * @param color the color to use for outline/fill + * @param esp when true uses ESP render type variants + * @param lineWidth line thickness for outlines + */ @JvmStatic fun drawBlockPos( - context: LevelRenderContext, - pos: BlockPos, - color: Color, - esp: Boolean = false, - lineWidth: Float = 1f, + context: LevelRenderContext, + pos: BlockPos, + color: Color, + esp: Boolean = false, + lineWidth: Float = 1f, ) { val box = AABB( - pos.x.toDouble(), - pos.y.toDouble(), - pos.z.toDouble(), - pos.x + 1.0, - pos.y + 1.0, - pos.z + 1.0 + pos.x.toDouble(), + pos.y.toDouble(), + pos.z.toDouble(), + pos.x + 1.0, + pos.y + 1.0, + pos.z + 1.0 ) drawBox(context, box, color, esp, lineWidth) } + /** + * Draw an outline around the provided entity's bounding box, interpolated for rendering. + * + * @param context the level render context to draw with + * @param entity the entity whose bounding box will be outlined + * @param color the outline color + * @param esp when true uses ESP render type variants + * @param lineWidth outline thickness + */ fun drawEntityOutline( - context: LevelRenderContext, - entity: Entity, - color: Color, - esp: Boolean = false, - lineWidth: Float = 1f, + context: LevelRenderContext, + entity: Entity, + color: Color, + esp: Boolean = false, + lineWidth: Float = 1f, ) { - val partialTicks = minecraft.deltaTracker.getGameTimeDeltaPartialTick(true) + val partialTicks = Cobalt.minecraft.deltaTracker.getGameTimeDeltaPartialTick(true) val interpolatedX = entity.xOld + (entity.x - entity.xOld) * partialTicks val interpolatedY = entity.yOld + (entity.y - entity.yOld) * partialTicks @@ -54,32 +79,46 @@ object RenderUtils { drawBox(context, entity.boundingBox.move(dx, dy, dz), color, esp, lineWidth) } + /** Draw a line from the camera position toward the supplied world-space target point. + * + * @param context the level render context to draw with + * @param to world-space target coordinate for the tracer + * @param color tracer color + * @param esp when true uses ESP render type variants + * @param lineWidth tracer thickness + */ @JvmStatic fun drawTracer( - context: LevelRenderContext, - to: Vec3, - color: Color, - esp: Boolean = true, - lineWidth: Float = 1f, + context: LevelRenderContext, + to: Vec3, + color: Color, + esp: Boolean = true, + lineWidth: Float = 1f, ) { - val camera = minecraft.gameRenderer.mainCamera + val camera = Cobalt.minecraft.gameRenderer.mainCamera val cameraPos = camera.position() val from = cameraPos.add(Vec3.directionFromRotation(camera.xRot(), camera.yRot())) drawLine(context, from, to, color, esp, lineWidth) } + /** Draw a colored axis-aligned bounding box (AABB) with optional translucent fill and outline. + * + * @param context the level render context to draw with + * @param box the world-space axis-aligned bounding box + * @param color color used for fill/outline + * @param esp when true uses ESP render type variants + * @param lineWidth outline thickness + */ @JvmStatic fun drawBox( - context: LevelRenderContext, - box: AABB, - color: Color, - esp: Boolean = false, - lineWidth: Float = 1f, + context: LevelRenderContext, + box: AABB, + color: Color, + esp: Boolean = false, + lineWidth: Float = 1f, ) { - if (color.alpha == 0) { - return - } + if (color.alpha == 0) return val frustum = context.levelState().cameraRenderState.cullFrustum @@ -87,19 +126,63 @@ object RenderUtils { return } + val cameraPos = Cobalt.minecraft.gameRenderer.mainCamera.position() + + val corners = arrayOf( + Vec3(box.minX, box.minY, box.minZ), Vec3(box.maxX, box.minY, box.minZ), + Vec3(box.maxX, box.minY, box.maxZ), Vec3(box.minX, box.minY, box.maxZ), + Vec3(box.minX, box.maxY, box.minZ), Vec3(box.maxX, box.maxY, box.minZ), + Vec3(box.maxX, box.maxY, box.maxZ), Vec3(box.minX, box.maxY, box.maxZ), + ) + + drawBoxQuads(context, corners, color, esp, cameraPos) + drawBoxLines(context, corners, color, esp, lineWidth, cameraPos) + } + + /** Draw a colored line between two world-space points. + * + * @param context the level render context to draw with + * @param from start point in world coordinates + * @param to end point in world coordinates + * @param color the color to use for the line + * @param esp whether ESP rendering is enabled + * @param lineWidth thickness of the line + */ + @JvmStatic + fun drawLine( + context: LevelRenderContext, + from: Vec3, + to: Vec3, + color: Color, + esp: Boolean = false, + lineWidth: Float = 1f, + ) { + if (color.alpha == 0) return + + val frustum = context.levelState().cameraRenderState.cullFrustum + + if (!FrustumUtils.isVisible( + frustum, + min(from.x, to.x), min(from.y, to.y), min(from.z, to.z), + max(from.x, to.x), max(from.y, to.y), max(from.z, to.z) + ) + ) return + + drawVisibleLine(context, from, to, color, esp, lineWidth) + } + + private fun drawBoxQuads( + context: LevelRenderContext, + corners: Array, + color: Color, + esp: Boolean, + cameraPos: Vec3, + ) { val poseStack = context.poseStack() val bufferSource = context.bufferSource() - val cameraPos = minecraft.gameRenderer.mainCamera.position() val matrix = poseStack.last().pose() - val poseEntry = poseStack.last() - val fillColor = Color(color.red, color.green, color.blue, 100) - val corners = arrayOf( - Vec3(box.minX, box.minY, box.minZ), Vec3(box.maxX, box.minY, box.minZ), - Vec3(box.maxX, box.minY, box.maxZ), Vec3(box.minX, box.minY, box.maxZ), - Vec3(box.minX, box.maxY, box.minZ), Vec3(box.maxX, box.maxY, box.minZ), - Vec3(box.maxX, box.maxY, box.maxZ), Vec3(box.minX, box.maxY, box.maxZ), - ) + val fillColor = Color(color.red, color.green, color.blue, ALPHA) val quadBuffer = bufferSource.getBuffer(Layers.getQuads(esp)) @@ -114,6 +197,20 @@ object RenderUtils { } bufferSource.endBatch(Layers.getQuads(esp)) + } + + private fun drawBoxLines( + context: LevelRenderContext, + corners: Array, + color: Color, + esp: Boolean, + lineWidth: Float, + cameraPos: Vec3, + ) { + val poseStack = context.poseStack() + val bufferSource = context.bufferSource() + val matrix = poseStack.last().pose() + val poseEntry = poseStack.last() val lineBuffer = bufferSource.getBuffer(Layers.getLines(esp)) @@ -122,69 +219,76 @@ object RenderUtils { val lineEnd = corners[BOX_LINES[i + 1]] val lineNormal = lineEnd.subtract(lineStart).normalize() - for (vertex in listOf(lineStart, lineEnd)) { - lineBuffer.addVertex( - matrix, - (vertex.x - cameraPos.x).toFloat(), - (vertex.y - cameraPos.y).toFloat(), - (vertex.z - cameraPos.z).toFloat(), - ) - .setLineWidth(lineWidth) - .setColor(color.red, color.green, color.blue, color.alpha) - .setNormal(poseEntry, lineNormal.x.toFloat(), lineNormal.y.toFloat(), lineNormal.z.toFloat()) - } + addBlockLineVertices(lineBuffer, matrix, poseEntry, lineStart, lineEnd, lineNormal, color, lineWidth, cameraPos) } bufferSource.endBatch(Layers.getLines(esp)) } - @JvmStatic - fun drawLine( - context: LevelRenderContext, - from: Vec3, - to: Vec3, - color: Color, - esp: Boolean = false, - lineWidth: Float = 1f, + private fun addBlockLineVertices( + lineBuffer: VertexConsumer, + matrix: Matrix4f, + poseEntry: PoseStack.Pose, + lineStart: Vec3, + lineEnd: Vec3, + lineNormal: Vec3, + color: Color, + lineWidth: Float, + cameraPos: Vec3, ) { - if (color.alpha == 0) { - return + for (vertex in listOf(lineStart, lineEnd)) { + lineBuffer.addVertex( + matrix, + (vertex.x - cameraPos.x).toFloat(), + (vertex.y - cameraPos.y).toFloat(), + (vertex.z - cameraPos.z).toFloat(), + ) + .setLineWidth(lineWidth) + .setColor(color.red, color.green, color.blue, color.alpha) + .setNormal(poseEntry, lineNormal.x.toFloat(), lineNormal.y.toFloat(), lineNormal.z.toFloat()) } + } - val frustum = context.levelState().cameraRenderState.cullFrustum + private fun drawVisibleLine( + context: LevelRenderContext, + from: Vec3, + to: Vec3, + color: Color, + esp: Boolean, + lineWidth: Float, + ) { + val bufferSource = context.bufferSource() + val lineBuffer = bufferSource.getBuffer(Layers.getLines(esp)) - if ( - !FrustumUtils.isVisible( - frustum, - min(from.x, to.x), min(from.y, to.y), min(from.z, to.z), - max(from.x, to.x), max(from.y, to.y), max(from.z, to.z), - ) - ) { - return - } + addLineVertices(context, lineBuffer, from, to, color, lineWidth) + + bufferSource.endBatch(Layers.getLines(esp)) + } + private fun addLineVertices( + context: LevelRenderContext, + lineBuffer: VertexConsumer, + from: Vec3, + to: Vec3, + color: Color, + lineWidth: Float, + ) { val poseStack = context.poseStack() - val bufferSource = context.bufferSource() - val cameraPos = minecraft.gameRenderer.mainCamera.position() + val cameraPos = Cobalt.minecraft.gameRenderer.mainCamera.position() val poseEntry = poseStack.last() - val matrix = poseEntry.pose() - val lineBuffer = bufferSource.getBuffer(Layers.getLines(esp)) val lineNormal = to.subtract(from).normalize() for (vertex in listOf(from, to)) { - lineBuffer - .addVertex( - matrix, - (vertex.x - cameraPos.x).toFloat(), - (vertex.y - cameraPos.y).toFloat(), - (vertex.z - cameraPos.z).toFloat() - ) + lineBuffer.addVertex( + poseEntry.pose(), + (vertex.x - cameraPos.x).toFloat(), + (vertex.y - cameraPos.y).toFloat(), + (vertex.z - cameraPos.z).toFloat() + ) .setLineWidth(lineWidth) .setColor(color.red, color.green, color.blue, color.alpha) .setNormal(poseEntry, lineNormal.x.toFloat(), lineNormal.y.toFloat(), lineNormal.z.toFloat()) } - - bufferSource.endBatch(Layers.getLines(esp)) } private val BOX_QUADS = intArrayOf( diff --git a/src/main/kotlin/org/cobalt/util/ServerUtils.kt b/src/main/kotlin/org/cobalt/util/ServerUtils.kt index 5967a635..e2bd6418 100644 --- a/src/main/kotlin/org/cobalt/util/ServerUtils.kt +++ b/src/main/kotlin/org/cobalt/util/ServerUtils.kt @@ -7,38 +7,59 @@ import org.cobalt.event.annotation.SubscribeEvent import org.cobalt.event.impl.PacketEvent import org.cobalt.mixin.client.AbstractClientPlayerAccessor +private const val DEFAULT_TPS = 20f +private const val TICKS_PER_SECOND = 20.0 +private const val MS_PER_SECOND = 1000.0 +private const val TPS_SMOOTHING = 0.05f + +/** + * Utility for server-related information. + */ object ServerUtils { - private var lastTickTime = 0L + private var lastTickTime = -1L - var averageTps = 20f + /** + * Average of the server's ticks per second (TPS). + */ + var averageTps = DEFAULT_TPS private set + /** + * Current network latency to the server in milliseconds. + * + * @return player's ping value, or 0 if unavailable + */ val currentPing - get() = (minecraft.player as AbstractClientPlayerAccessor).clientPlayerInfo?.latency ?: 0 + get() = (minecraft.player as AbstractClientPlayerAccessor) + .clientPlayerInfo?.latency ?: 0 init { EventBus.register(this) } + @Suppress("UndocumentedPublicFunction") @SubscribeEvent - fun onPacketReceive(event: PacketEvent.Receive) { - if (event.packet is ClientboundSetTimePacket) { - val now = System.currentTimeMillis() + fun onPacketReceive(@Suppress("UnusedParameter") event: PacketEvent.Receive) { + if (event.packet !is ClientboundSetTimePacket) return + + val now = System.currentTimeMillis() + + val last = lastTickTime + lastTickTime = now - if (lastTickTime == 0L) { - lastTickTime = now - return - } + if (last == -1L) return - val delta = now - lastTickTime - lastTickTime = now + val delta = now - last + if (delta <= 0) return - if (delta <= 0) return + val tps = (MS_PER_SECOND * TICKS_PER_SECOND / delta) + .coerceAtMost(TICKS_PER_SECOND) + .toFloat() - val tps = (20000.0 / delta).coerceIn(0.0, 20.0) - averageTps = (averageTps * 0.95 + tps * 0.05).toFloat() - } + averageTps = + averageTps * (1 - TPS_SMOOTHING) + + tps * TPS_SMOOTHING } } diff --git a/src/main/kotlin/org/cobalt/util/WebUtils.kt b/src/main/kotlin/org/cobalt/util/WebUtils.kt index 64d2b584..ecdc1541 100644 --- a/src/main/kotlin/org/cobalt/util/WebUtils.kt +++ b/src/main/kotlin/org/cobalt/util/WebUtils.kt @@ -5,8 +5,21 @@ import java.net.HttpURLConnection import java.net.URI import org.cobalt.Cobalt +/** + * Utility for web-related operations. + */ object WebUtils { + /** + * Opens an InputStream for the given URL using an HTTP GET request. + * + * The caller is responsible for closing the returned stream. + * + * @param url target URL + * @param timeout connection and read timeout in milliseconds + * @param cache whether URLConnection caching is enabled + * @return [InputStream] of the HTTP response body + */ @JvmStatic fun getInputStream(url: String, timeout: Int = 5000, cache: Boolean = true): InputStream { val connection = (URI(url).toURL().openConnection() as HttpURLConnection).apply { diff --git a/src/main/kotlin/org/cobalt/util/WindowUtils.kt b/src/main/kotlin/org/cobalt/util/WindowUtils.kt new file mode 100644 index 00000000..cf654dcb --- /dev/null +++ b/src/main/kotlin/org/cobalt/util/WindowUtils.kt @@ -0,0 +1,51 @@ +package org.cobalt.util + +import kotlin.math.min +import org.cobalt.Cobalt.minecraft +import org.cobalt.dsl.mouseX +import org.cobalt.dsl.mouseY + +/** + * Utility for handling mouse input and cursor state within the client. + * + * Provides access to mouse position and controls for cursor interaction. + */ +object WindowUtils { + + private const val BASE_WIDTH = 1920f + private const val BASE_HEIGHT = 1080f + + /** + * Calculates a UI scaling factor based on the current window size. + * + * The scale is derived by comparing the current window dimensions to a + * fixed base resolution (1920x1080) and returning the smaller ratio + * to maintain aspect consistency. + * + * @return scale factor used for UI rendering + */ + @JvmStatic + fun getWindowScale(): Float { + val windowWidth = minecraft.window.width.toFloat() + val windowHeight = minecraft.window.height.toFloat() + + return min(windowWidth / BASE_WIDTH, windowHeight / BASE_HEIGHT) + } + + /** + * Gets the current mouse X position in screen space. + * + * @return mouse X coordinate in window space + */ + @JvmStatic + fun getMouseX(): Float = mouseX + + /** + * Gets the current mouse Y position in screen space. + * + * @return mouse Y coordinate in window space + */ + @JvmStatic + fun getMouseY(): Float = mouseY + +} diff --git a/src/main/kotlin/org/cobalt/util/helper/Layers.kt b/src/main/kotlin/org/cobalt/util/helper/Layers.kt index d287282b..ed5e5643 100644 --- a/src/main/kotlin/org/cobalt/util/helper/Layers.kt +++ b/src/main/kotlin/org/cobalt/util/helper/Layers.kt @@ -6,6 +6,9 @@ import net.minecraft.client.renderer.rendertype.OutputTarget import net.minecraft.client.renderer.rendertype.RenderSetup import net.minecraft.client.renderer.rendertype.RenderType +/** + * Collection of predefined RenderType layers used for custom rendering. + */ object Layers { private val LINES: RenderType = RenderType.create( @@ -40,10 +43,24 @@ object Layers { .createRenderSetup() ) + /** + * Returns the appropriate quad RenderType based on ESP mode. + * + * @param esp whether ESP rendering is enabled + * @return the corresponding quad RenderType + */ + @JvmStatic fun getQuads(esp: Boolean): RenderType { return if (esp) QUADS_ESP else QUADS } + /** + * Returns the appropriate line RenderType based on ESP mode. + * + * @param esp whether ESP rendering is enabled + * @return the corresponding line RenderType + */ + @JvmStatic fun getLines(esp: Boolean): RenderType { return if (esp) LINES_ESP else LINES } diff --git a/src/main/kotlin/org/cobalt/util/helper/Pipelines.kt b/src/main/kotlin/org/cobalt/util/helper/Pipelines.kt index e1475d27..00c16b95 100644 --- a/src/main/kotlin/org/cobalt/util/helper/Pipelines.kt +++ b/src/main/kotlin/org/cobalt/util/helper/Pipelines.kt @@ -6,26 +6,49 @@ import java.util.* import net.minecraft.client.renderer.RenderPipelines import net.minecraft.resources.Identifier +/** + * Collection of custom RenderPipeline definitions used for rendering. + */ object Pipelines { + private const val NAMESPACE = "cobalt" + private const val PIPELINE_DIR = "pipeline" + private const val LINES_ESP_PATH = "$PIPELINE_DIR/lines_esp" + private const val QUADS_PATH = "$PIPELINE_DIR/quads" + private const val QUADS_ESP_PATH = "$PIPELINE_DIR/quads_esp" + + private val NO_DEPTH_STENCIL: Optional = Optional.empty() + + /** + * Render pipeline for line rendering with ESP enabled (visible through walls). + */ + @JvmStatic val LINES_ESP: RenderPipeline = RenderPipelines.register( RenderPipeline.builder(RenderPipelines.LINES_SNIPPET) - .withLocation(Identifier.fromNamespaceAndPath("cobalt", "pipeline/lines_esp")) - .withDepthStencilState(Optional.empty()) + .withLocation(Identifier.fromNamespaceAndPath(NAMESPACE, LINES_ESP_PATH)) + .withDepthStencilState(NO_DEPTH_STENCIL) .build() ) + /** + * Render pipeline for standard quad rendering with normal depth testing. + */ + @JvmStatic val QUADS: RenderPipeline = RenderPipelines.register( RenderPipeline.builder(RenderPipelines.DEBUG_FILLED_SNIPPET) - .withLocation(Identifier.fromNamespaceAndPath("cobalt", "pipeline/quads")) + .withLocation(Identifier.fromNamespaceAndPath(NAMESPACE, QUADS_PATH)) .withDepthStencilState(DepthStencilState.DEFAULT) .build() ) + /** + * Render pipeline for quad rendering with ESP enabled (visible through walls). + */ + @JvmStatic val QUADS_ESP: RenderPipeline = RenderPipelines.register( RenderPipeline.builder(RenderPipelines.DEBUG_FILLED_SNIPPET) - .withLocation(Identifier.fromNamespaceAndPath("cobalt", "pipeline/quads_esp")) - .withDepthStencilState(Optional.empty()) + .withLocation(Identifier.fromNamespaceAndPath(NAMESPACE, QUADS_ESP_PATH)) + .withDepthStencilState(NO_DEPTH_STENCIL) .build() ) diff --git a/src/main/kotlin/org/cobalt/util/helper/TickScheduler.kt b/src/main/kotlin/org/cobalt/util/helper/TickScheduler.kt index 36f92c0d..1b101a62 100644 --- a/src/main/kotlin/org/cobalt/util/helper/TickScheduler.kt +++ b/src/main/kotlin/org/cobalt/util/helper/TickScheduler.kt @@ -5,6 +5,9 @@ import org.cobalt.event.EventBus import org.cobalt.event.annotation.SubscribeEvent import org.cobalt.event.impl.TickEvent +/** + * Utility for scheduling delayed tasks based on client tick updates. + */ object TickScheduler { private val taskQueue = PriorityQueue(Comparator.comparingLong(ScheduledTask::executeTick)) @@ -16,13 +19,20 @@ object TickScheduler { EventBus.register(this) } + /** + * Schedules a task to be executed after a given number of client ticks. + * + * @param delayTicks number of ticks to wait before executing the task + * @param action the task to execute after the delay + */ @JvmStatic fun schedule(delayTicks: Long, action: Runnable) { taskQueue.offer(ScheduledTask(currentTick + delayTicks, action)) } + @Suppress("UndocumentedPublicFunction") @SubscribeEvent - fun onClientTick(event: TickEvent.End) { + fun onClientTick(@Suppress("UnusedParameter") event: TickEvent.End) { currentTick++ var task: ScheduledTask? diff --git a/src/main/kotlin/org/cobalt/util/rotation/DefaultRotations.kt b/src/main/kotlin/org/cobalt/util/rotation/DefaultRotations.kt deleted file mode 100644 index 5ecc2563..00000000 --- a/src/main/kotlin/org/cobalt/util/rotation/DefaultRotations.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.cobalt.util.rotation - -import kotlin.math.abs -import org.cobalt.Cobalt.minecraft -import org.cobalt.util.ChatUtils -import org.cobalt.util.MessageType - -object DefaultRotations : IRotation { - - private var rotating = false - private var targetYaw = 0.0 - private var targetPitch = 0.0 - private var currentYaw = 0.0 - private var currentPitch = 0.0 - private var currentSpeed = 0.0 - - override fun onRotationStart(yaw: Double, pitch: Double, speed: Double) { - rotating = true - targetYaw = yaw - targetPitch = pitch - currentYaw = getPlayerYaw() - currentPitch = getPlayerPitch() - currentSpeed = speed - ChatUtils.sendMessage("Rotation started to $yaw, $pitch", MessageType.DEBUG) - } - - override fun onRotationEnd() { - rotating = false - ChatUtils.sendMessage("Ended rotation.", MessageType.DEBUG) - } - - override fun onRotationWorldRender() { - if (!rotating) return - - val player = getPlayer() ?: return - - val currentYaw = player.yRot.toDouble() - val currentPitch = player.xRot.toDouble() - - - val newYaw = lerpAngle(currentYaw, targetYaw, currentSpeed) - val newPitch = lerp(currentPitch, targetPitch, currentSpeed) - - applyRotation(newYaw, newPitch) - - if ( - angleDistance(newYaw, targetYaw) < 0.5 && - abs(newPitch - targetPitch) < 0.5 - ) { - stopRotation() - } - } - // lerp! - private fun lerpAngle(current: Double, target: Double, alpha: Double): Double { - val delta = ((target - current + 540) % 360) - 180 - return current + delta * alpha - } - - private fun lerp(a: Double, b: Double, t: Double): Double { - return a + (b - a) * t - } - - private fun angleDistance(a: Double, b: Double): Double { - val d = ((b - a + 540) % 360) - 180 - return abs(d) - } - private fun distance(a: Double, b: Double): Double { - return abs(a - b) - } - - override fun isRotating(): Boolean = rotating - - private fun applyRotation(yaw: Double, pitch: Double) { - val player = getPlayer() ?: return - - val y = yaw.toFloat() - val p = pitch.toFloat() - - player.yRot = y - player.xRot = p - } - - private fun getPlayerYaw(): Double { - return getPlayer()?.yRot?.toDouble() ?: 0.0 - } - - private fun getPlayerPitch(): Double { - return getPlayer()?.xRot?.toDouble() ?: 0.0 - } - - private fun getPlayer() = minecraft.player - -} diff --git a/src/main/kotlin/org/cobalt/util/rotation/IRotation.kt b/src/main/kotlin/org/cobalt/util/rotation/IRotation.kt deleted file mode 100644 index 8d58686f..00000000 --- a/src/main/kotlin/org/cobalt/util/rotation/IRotation.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.cobalt.util.rotation - -interface IRotation { - - fun onRotationWorldRender() - fun onRotationEnd() - fun onRotationStart(yaw: Double, pitch: Double, speed: Double = 0.15) - fun isRotating(): Boolean - - fun stopRotation() { - onRotationEnd() - } - -} diff --git a/src/main/kotlin/org/cobalt/util/rotation/RotationManager.kt b/src/main/kotlin/org/cobalt/util/rotation/RotationManager.kt deleted file mode 100644 index 7f605760..00000000 --- a/src/main/kotlin/org/cobalt/util/rotation/RotationManager.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.cobalt.util.rotation - -import org.cobalt.event.EventBus -import org.cobalt.event.annotation.SubscribeEvent -import org.cobalt.event.impl.WorldRenderEvent - -object RotationManager { - - private var rotation: IRotation = DefaultRotations - - init { - EventBus.register(this) - } - - fun setActiveRotation(newRotation: IRotation, yaw: Double, pitch: Double) { - if (rotation.isRotating()) { - rotation.stopRotation() - } - - rotation = newRotation - rotation.onRotationStart(yaw, pitch) - } - - fun getActiveRotation(): IRotation = rotation - - fun resetRotation() { - if (rotation.isRotating()) { - rotation.stopRotation() - } - - rotation = DefaultRotations - } - - @SubscribeEvent - fun onWorldRender(event: WorldRenderEvent) { - if (!rotation.isRotating()) return - rotation.onRotationWorldRender() - } - -} diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaContext.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaContext.kt index 872a603e..e3984e36 100644 --- a/src/main/kotlin/org/cobalt/util/skia/SkiaContext.kt +++ b/src/main/kotlin/org/cobalt/util/skia/SkiaContext.kt @@ -18,12 +18,26 @@ package org.cobalt.util.skia -import io.github.humbleui.skija.* +import io.github.humbleui.skija.Canvas +import io.github.humbleui.skija.ColorSpace +import io.github.humbleui.skija.ColorType +import io.github.humbleui.skija.DirectContext +import io.github.humbleui.skija.FramebufferFormat +import io.github.humbleui.skija.Surface +import io.github.humbleui.skija.SurfaceOrigin import org.cobalt.event.EventBus import org.cobalt.event.impl.SkiaDrawEvent import org.cobalt.util.skia.gl.States import org.lwjgl.opengl.GL11 +private const val DEFAULT_SAMPLES = 0 +private const val DEFAULT_STENCIL_BITS = 8 +private const val DEFAULT_PREFER_SAMPLES = 0 +private const val CLEAR_R = 0f +private const val CLEAR_G = 0f +private const val CLEAR_B = 0f +private const val CLEAR_A_TRANSPARENT = 0f + internal object SkiaContext { private var context: DirectContext? = null @@ -37,15 +51,31 @@ internal object SkiaContext { EventBus.register(this) } - fun initSkia(width: Int, height: Int) { - if (context == null) { - context = DirectContext.makeGL() - } + internal fun initSkia(width: Int, height: Int) { + ensureContext() + + recreateRenderTarget(width, height) + + canvas = surface?.canvas + } + private fun ensureContext() { + if (context == null) context = DirectContext.makeGL() + } + + private fun recreateRenderTarget(width: Int, height: Int) { surface?.close() renderTarget?.close() - renderTarget = WrappedBackendRenderTarget.makeGL(width, height, 0, 8, 0, FramebufferFormat.GR_GL_RGBA8) + renderTarget = WrappedBackendRenderTarget.makeGL( + width, + height, + DEFAULT_SAMPLES, + DEFAULT_STENCIL_BITS, + DEFAULT_PREFER_SAMPLES, + FramebufferFormat.GR_GL_RGBA8 + ) + surface = Surface.wrapBackendRenderTarget( requireNotNull(context), requireNotNull(renderTarget), @@ -53,28 +83,25 @@ internal object SkiaContext { ColorType.RGBA_8888, ColorSpace.getSRGB() ) - - canvas = surface?.canvas } - fun draw() { - if (context == null || surface == null) return + internal fun draw() { + val ctx = context ?: return + val srf = surface ?: return States.push() GL11.glDisable(GL11.GL_CULL_FACE) - GL11.glClearColor(0f, 0f, 0f, 0f) + GL11.glClearColor(CLEAR_R, CLEAR_G, CLEAR_B, CLEAR_A_TRANSPARENT) - context?.resetGLAll() + ctx.resetGLAll() - canvas?.let { canvas -> - context?.let { context -> - renderTarget?.let { renderTarget -> - EventBus.post(SkiaDrawEvent(context, renderTarget, canvas)) - } - } + val cvs = canvas + val rt = renderTarget + if (cvs != null && rt != null) { + EventBus.post(SkiaDrawEvent(ctx, rt, cvs)) } - context?.flushAndSubmit(surface) + ctx.flushAndSubmit(srf) States.pop() } diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaEnums.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaEnums.kt index c520f26c..aa43f3d5 100644 --- a/src/main/kotlin/org/cobalt/util/skia/SkiaEnums.kt +++ b/src/main/kotlin/org/cobalt/util/skia/SkiaEnums.kt @@ -1,10 +1,33 @@ package org.cobalt.util.skia +/** + * Direction for gradient interpolation in Skia rendering helpers + */ enum class SkiaGradient { + + /** Gradient flows from top to bottom (vertical interpolation). */ TOP_TO_BOTTOM, + + /** Gradient flows from left to right (horizontal interpolation). */ LEFT_TO_RIGHT + } +/** + * A side of a rectangle, used for edge-based drawing operations. + */ enum class SkiaSide { - TOP, BOTTOM, LEFT, RIGHT + + /** The top edge of a rectangle. */ + TOP, + + /** The bottom edge of a rectangle. */ + BOTTOM, + + /** The left edge of a rectangle. */ + LEFT, + + /** The right edge of a rectangle. */ + RIGHT + } diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaImage.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaImage.kt index 772c7d68..b92254c9 100644 --- a/src/main/kotlin/org/cobalt/util/skia/SkiaImage.kt +++ b/src/main/kotlin/org/cobalt/util/skia/SkiaImage.kt @@ -11,10 +11,23 @@ import java.nio.file.Files import kotlinx.coroutines.runBlocking import org.cobalt.util.WebUtils +/** + * Image wrapper used by [SkiaImages]. + * + * @property isSvg whether the image source is an SVG file + * @property image raster image instance (null if SVG) + * @property svgDom parsed SVG document (null if raster image) + * + * @param identifier path or URL to the image resource + * @property radius optional corner radius for rendering + * @property colorMask optional ARGB color mask applied to the image + * + * @see SkiaImages + */ class SkiaImage(identifier: String, val radius: Float? = null, val colorMask: Int? = null) { val isSvg = identifier.endsWith(".svg", ignoreCase = true) - val skiaImage: Image? + val image: Image? val svgDom: SVGDOM? private var cachedRaster: Image? = null @@ -26,48 +39,82 @@ class SkiaImage(identifier: String, val radius: Float? = null, val colorMask: In if (isSvg) { svgDom = Data.makeFromBytes(bytes).use { data -> SVGDOM(data) } - skiaImage = null + image = null } else { - skiaImage = Image.makeDeferredFromEncodedBytes(bytes) + image = Image.makeDeferredFromEncodedBytes(bytes) svgDom = null } } + /** + * Returns a rasterized image for the given size. + * + * For SVG sources, the image is generated and cached per size. + * + * @param width target width + * @param height target height + * @return raster image or null if unavailable + */ fun getOrGenerateRaster(width: Int, height: Int): Image? { - if (!isSvg) return skiaImage + if (!isSvg) return image val dom = svgDom ?: return null - if (cachedRaster != null && width == lastWidth && height == lastHeight) { - return cachedRaster + if (!isCachedMatch(width, height)) { + cachedRaster?.close() + val generated = generateRaster(dom, width, height) + if (generated != null) { + cachedRaster = generated + lastWidth = width + lastHeight = height + } } + return cachedRaster + } + + /** + * Releases all underlying Skia resources. + */ + fun delete() { + image?.close() + svgDom?.close() cachedRaster?.close() + } + private fun isCachedMatch(width: Int, height: Int): Boolean { + return cachedRaster != null && width == lastWidth && height == lastHeight + } + + private fun generateRaster(dom: SVGDOM, width: Int, height: Int): Image? { val root = dom.root ?: return null val sourceWidth = root.width.value.takeIf { it > 0 } ?: width.toFloat() val sourceHeight = root.height.value.takeIf { it > 0 } ?: height.toFloat() + return renderDomToSurface(dom, width, height, sourceWidth, sourceHeight) + } + + private fun renderDomToSurface( + dom: SVGDOM, + width: Int, + height: Int, + sourceWidth: Float, + sourceHeight: Float, + ): Image? { + var snapshot: Image? = null Surface.makeRaster(ImageInfo.makeN32Premul(width, height)).use { surface -> surface.canvas.apply { scale(width / sourceWidth, height / sourceHeight) dom.render(this) } - cachedRaster = surface.makeImageSnapshot() + snapshot = surface.makeImageSnapshot() } - lastWidth = width - lastHeight = height - return cachedRaster - } - - fun delete() { - skiaImage?.close() - svgDom?.close() - cachedRaster?.close() + return snapshot } companion object { + private fun getByteArray(path: String): ByteArray { val trimmedPath = path.trim() return if (trimmedPath.startsWith("http")) runBlocking { WebUtils.getInputStream(trimmedPath).readBytes() } @@ -77,6 +124,7 @@ class SkiaImage(identifier: String, val radius: Float? = null, val colorMask: In else this::class.java.getResourceAsStream(trimmedPath)?.readBytes() ?: throw FileNotFoundException(trimmedPath) } } + } } diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaImages.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaImages.kt new file mode 100644 index 00000000..ed08aba4 --- /dev/null +++ b/src/main/kotlin/org/cobalt/util/skia/SkiaImages.kt @@ -0,0 +1,136 @@ +package org.cobalt.util.skia + +import io.github.humbleui.skija.BlendMode +import io.github.humbleui.skija.Canvas +import io.github.humbleui.skija.ClipMode +import io.github.humbleui.skija.ColorFilter +import io.github.humbleui.skija.Image +import io.github.humbleui.skija.Paint +import io.github.humbleui.skija.SamplingMode +import io.github.humbleui.types.RRect +import io.github.humbleui.types.Rect +import org.cobalt.math.Dimensions +import org.cobalt.math.Vec2f +import org.cobalt.util.skia.SkiaContext.canvas + +/** + * Utility for loading, caching, and drawing images via Skia. + */ +object SkiaImages { + + private val images = mutableMapOf() + + /** + * Loads an image configuration and caches it by identifier, radius, and color mask. + * + * Radius values <= 0 are normalized to `null` (no rounded clipping). + * + * @param identifier image/resource identifier + * @param radius optional corner radius for rounded clipping + * @param colorMask optional ARGB color mask applied during draw + * @return cached [SkiaImage] instance for the given configuration + */ + @JvmStatic + fun loadImage( + identifier: String, + radius: Float? = null, + colorMask: Int? = null, + ): SkiaImage { + val normalizedRadius = radius?.takeIf { it > 0f } + val key = ImageCacheKey(identifier, normalizedRadius, colorMask) + + return images.computeIfAbsent(key) { + SkiaImage(identifier, normalizedRadius, colorMask) + } + } + + /** + * Draws a configured image to the current Skia canvas. + * + * Returns early when dimensions are invalid, the canvas is unavailable, + * or raster generation fails. + * + * @param image configured [SkiaImage] to draw + * @param pos target position in screen space + */ + @JvmStatic + fun drawImage(image: SkiaImage, pos: Vec2f, dim: Dimensions) { + if (!isValidDimension(dim)) return + val canvas = canvas ?: return + + val sourceImage = image.getOrGenerateRaster(dim.width.toInt(), dim.height.toInt()) ?: return + + drawConfiguredImage(canvas, image, pos, dim, sourceImage) + } + + private fun drawConfiguredImage( + canvas: Canvas, + image: SkiaImage, + pos: Vec2f, + dim: Dimensions, + sourceImage: Image, + ) { + Paint().use { paint -> + configurePaint(paint, image.colorMask) + drawWithOptionalClip(canvas, image, pos, dim, sourceImage, paint) + } + } + + private fun drawWithOptionalClip( + canvas: Canvas, + image: SkiaImage, + pos: Vec2f, + dim: Dimensions, + sourceImage: Image, + paint: Paint, + ) { + val roundedRect = if (image.radius != null && image.radius > 0f) { + RRect.makeXYWH(pos.x, pos.y, dim.width, dim.height, image.radius) + } else null + + withOptionalClip(canvas, roundedRect) { + canvas.drawImageRect( + sourceImage, + Rect.makeWH(sourceImage.width.toFloat(), sourceImage.height.toFloat()), + Rect.makeXYWH(pos.x, pos.y, dim.width, dim.height), + SamplingMode.MITCHELL, + paint, + false + ) + } + } + + private fun configurePaint(paint: Paint, colorMask: Int?) { + if (colorMask != null) { + paint.colorFilter = ColorFilter.makeBlend(colorMask, BlendMode.SRC_ATOP) + } + } + + private inline fun withOptionalClip( + canvas: Canvas, + roundedRect: RRect?, + block: () -> Unit, + ) { + if (roundedRect != null) { + canvas.save() + canvas.clipRRect(roundedRect, ClipMode.INTERSECT, true) + try { + block() + } finally { + canvas.restore() + } + } else { + block() + } + } + + private fun isValidDimension(dim: Dimensions) = + dim.width > 0 && dim.height > 0 + + private data class ImageCacheKey( + val identifier: String, + val radius: Float?, + val colorMask: Int?, + ) + +} diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaRenderer.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaRenderer.kt deleted file mode 100644 index e991acd1..00000000 --- a/src/main/kotlin/org/cobalt/util/skia/SkiaRenderer.kt +++ /dev/null @@ -1,358 +0,0 @@ -package org.cobalt.util.skia - -import io.github.humbleui.skija.* -import io.github.humbleui.types.RRect -import io.github.humbleui.types.Rect -import java.io.IOException -import org.cobalt.Cobalt.minecraft - -object SkiaRenderer { - - private data class ImageCacheKey( - val identifier: String, - val radius: Float?, - val colorMask: Int?, - ) - - private val fonts = mutableMapOf() - private val images = mutableMapOf() - private var scissorStackDepth = 0 - - val primaryFont = loadFont("assets/cobalt/font/ProductSans-Bold.ttf") - - private val canvas: Canvas? - get() = SkiaContext.canvas - - fun getWindowScale(): Float { - val baseWidth = 1920f - val baseHeight = 1080f - - val windowWidth = minecraft.window.width.toFloat() - val windowHeight = minecraft.window.height.toFloat() - - return minOf(windowWidth / baseWidth, windowHeight / baseHeight) - } - - @JvmStatic - fun save() = - this.canvas?.save() - - @JvmStatic - fun restore() = - this.canvas?.restore() - - @JvmStatic - fun translate(x: Float, y: Float) = - this.canvas?.translate(x, y) - - @JvmStatic - fun rotate(angleDeg: Float) = - this.canvas?.rotate(angleDeg) - - @JvmStatic - fun scale(x: Float, y: Float) = - this.canvas?.scale(x, y) - - @JvmStatic - fun pushScissor(x: Float, y: Float, width: Float, height: Float) { - val canvas = this.canvas ?: return - - if (width <= 0 || height <= 0) { - return - } - - canvas.save() - canvas.clipRect(Rect.makeXYWH(x, y, width, height), ClipMode.INTERSECT, true) - scissorStackDepth++ - } - - @JvmStatic - fun popScissor() { - if (scissorStackDepth <= 0) return - canvas?.restore() - scissorStackDepth-- - } - - @JvmStatic - fun loadFont(resourcePath: String) = fonts.computeIfAbsent(resourcePath) { - val bytes = javaClass.classLoader - ?.getResourceAsStream(resourcePath) - ?.use { it.readAllBytes() } - ?: throw IOException("Font resource not found: $resourcePath") - - val font = FontMgr.getDefault().makeFromData(Data.makeFromBytes(bytes)) - ?: throw IllegalArgumentException("Invalid font data: $resourcePath") - - Font(font).apply { - isSubpixel = false - hinting = FontHinting.NORMAL - edging = FontEdging.ANTI_ALIAS - } - } - - @JvmStatic - fun text(font: Font, text: String, x: Float, y: Float, fontSize: Float, color: Int) { - val canvas = this.canvas ?: return - font.size = fontSize - - TextLine.make(text, font).use { line -> - val baseline = y - line.ascent - 1f - - Paint().setColor(color).use { paint -> - canvas.drawTextLine(line, x, baseline, paint) - } - } - } - - @JvmStatic - fun textWidth(font: Font, text: String, fontSize: Float): Float { - font.size = fontSize - - TextLine.make(text, font).use { line -> - return line.width - } - } - - @JvmStatic - fun loadImage( - identifier: String, - radius: Float? = null, - colorMask: Int? = null, - ): SkiaImage { - val normalizedRadius = radius?.takeIf { it > 0f } - val key = ImageCacheKey(identifier, normalizedRadius, colorMask) - - return images.computeIfAbsent(key) { - SkiaImage(identifier, normalizedRadius, colorMask) - } - } - - @JvmStatic - fun image(image: SkiaImage, x: Float, y: Float, width: Float, height: Float) { - val canvas = this.canvas ?: return - if (width <= 0 || height <= 0) return - - val sourceImage = image.getOrGenerateRaster(width.toInt(), height.toInt()) ?: return - - Paint().use { paint -> - image.colorMask?.let { - paint.colorFilter = ColorFilter.makeBlend(it, BlendMode.SRC_ATOP) - } - - if (image.radius != null && image.radius > 0f) { - canvas.save() - canvas.clipRRect(RRect.makeXYWH(x, y, width, height, image.radius), ClipMode.INTERSECT, true) - } - - canvas.drawImageRect( - sourceImage, - Rect.makeWH(sourceImage.width.toFloat(), sourceImage.height.toFloat()), - Rect.makeXYWH(x, y, width, height), - SamplingMode.MITCHELL, - paint, - false - ) - - if (image.radius != null && image.radius > 0f) { - canvas.restore() - } - } - } - - @JvmStatic - fun line(x1: Float, x2: Float, y1: Float, y2: Float, color: Int, thickness: Float = 1f) { - val canvas = this.canvas ?: return - - Paint().apply { - setColor(color) - mode = PaintMode.STROKE - strokeWidth = thickness.coerceAtLeast(0f) - isAntiAlias = true - }.use { paint -> - canvas.drawLine(x1, y1, x2, y2, paint) - } - } - - @JvmStatic - fun rect(x: Float, y: Float, width: Float, height: Float, color: Int) { - val canvas = this.canvas ?: return - - if (width <= 0f || height <= 0f) { - return - } - - Paint().setColor(color).use { paint -> - canvas.drawRect(Rect.makeXYWH(x, y, width, height), paint) - } - } - - @JvmStatic - fun outline(x: Float, y: Float, width: Float, height: Float, color: Int, thickness: Float = 1f) { - val canvas = this.canvas ?: return - - if (width <= 0f || height <= 0f) { - return - } - - val thickness = thickness.coerceAtLeast(0f) - val half = thickness / 2f - - Paint().apply { - setColor(color) - mode = PaintMode.STROKE - strokeWidth = thickness - isAntiAlias = true - }.use { paint -> - canvas.drawRect( - Rect.makeXYWH(x + half, y + half, width - thickness, height - thickness), - paint, - ) - } - } - - @JvmStatic - fun roundedRect(x: Float, y: Float, width: Float, height: Float, radius: Float, color: Int) { - val canvas = this.canvas ?: return - - if (width <= 0f || height <= 0f) { - return - } - - Paint().setColor(color).use { paint -> - canvas.drawRRect(RRect.makeXYWH(x, y, width, height, radius.coerceAtLeast(0f)), paint) - } - } - - @JvmStatic - fun roundedOutline( - x: Float, - y: Float, - width: Float, - height: Float, - radius: Float, - color: Int, - thickness: Float = 1f, - ) { - val canvas = this.canvas ?: return - - if (width <= 0f || height <= 0f) { - return - } - - val thickness = thickness.coerceAtLeast(1f) - val half = thickness / 2f - val innerRadius = (radius - half).coerceAtLeast(0f) - - Paint().apply { - setColor(color) - mode = PaintMode.STROKE - strokeWidth = thickness - isAntiAlias = true - }.use { paint -> - canvas.drawRRect( - RRect.makeXYWH(x + half, y + half, width - thickness, height - thickness, innerRadius), - paint, - ) - } - } - - @JvmStatic - fun gradientRect( - x: Float, y: Float, width: Float, height: Float, - colorStart: Int, colorEnd: Int, direction: SkiaGradient, - ) { - val canvas = this.canvas ?: return - - if (width <= 0f || height <= 0f) { - return - } - - val x1 = when (direction) { - SkiaGradient.LEFT_TO_RIGHT -> x + width - SkiaGradient.TOP_TO_BOTTOM -> x - } - - val y1 = when (direction) { - SkiaGradient.TOP_TO_BOTTOM -> y + height - SkiaGradient.LEFT_TO_RIGHT -> y - } - - Shader.makeLinearGradient( - x, y, x1, y1, - intArrayOf(colorStart, colorEnd) - ).use { shader -> - Paint().apply { - this.shader = shader - isAntiAlias = true - }.use { paint -> - canvas.drawRect(Rect.makeXYWH(x, y, width, height), paint) - } - } - } - - @JvmStatic - fun gradientRoundedRect( - x: Float, - y: Float, - width: Float, - height: Float, - radius: Float, - colorStart: Int, - colorEnd: Int, - direction: SkiaGradient, - ) { - val canvas = this.canvas ?: return - - if (width <= 0f || height <= 0f) { - return - } - - val x1 = when (direction) { - SkiaGradient.LEFT_TO_RIGHT -> x + width - SkiaGradient.TOP_TO_BOTTOM -> x - } - - val y1 = when (direction) { - SkiaGradient.TOP_TO_BOTTOM -> y + height - SkiaGradient.LEFT_TO_RIGHT -> y - } - - Shader.makeLinearGradient( - x, y, x1, y1, - intArrayOf(colorStart, colorEnd) - ).use { shader -> - Paint().apply { - this.shader = shader - isAntiAlias = true - }.use { paint -> - canvas.drawRRect(RRect.makeXYWH(x, y, width, height, radius.coerceAtLeast(0f)), paint) - } - } - } - - @JvmStatic - fun halfRoundedRect( - x: Float, y: Float, width: Float, height: Float, - radius: Float, color: Int, side: SkiaSide = SkiaSide.TOP, - ) { - val canvas = this.canvas ?: return - if (width <= 0f || height <= 0f) return - - val r = radius.coerceAtLeast(0f) - - val radii = when (side) { - SkiaSide.TOP -> floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f) - SkiaSide.BOTTOM -> floatArrayOf(0f, 0f, 0f, 0f, r, r, r, r) - SkiaSide.LEFT -> floatArrayOf(r, r, 0f, 0f, 0f, 0f, r, r) - SkiaSide.RIGHT -> floatArrayOf(0f, 0f, r, r, r, r, 0f, 0f) - } - - Paint().apply { - setColor(color) - isAntiAlias = true - }.use { paint -> - canvas.drawRRect(RRect.makeComplexXYWH(x, y, width, height, radii), paint) - } - } - -} diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaShapes.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaShapes.kt new file mode 100644 index 00000000..0debf835 --- /dev/null +++ b/src/main/kotlin/org/cobalt/util/skia/SkiaShapes.kt @@ -0,0 +1,296 @@ +package org.cobalt.util.skia + +import io.github.humbleui.skija.Paint +import io.github.humbleui.skija.PaintMode +import io.github.humbleui.skija.Shader +import io.github.humbleui.types.RRect +import io.github.humbleui.types.Rect +import org.cobalt.math.Dimensions +import org.cobalt.math.Vec2f +import org.cobalt.util.skia.SkiaContext.canvas + +/** + * Utility for drawing basic shape primitives via Skia. + */ +object SkiaShapes { + + /** + * Draws a line segment between two points. + * + * No-op when no canvas is available. + * + * @param start line start position + * @param end line end position + * @param color ARGB line color + * @param thickness stroke width in pixels (clamped to >= 0) + */ + @JvmStatic + fun drawLine(start: Vec2f, end: Vec2f, color: Int, thickness: Float = 1f) { + val canvas = canvas ?: return + + Paint().apply { + setColor(color) + mode = PaintMode.STROKE + strokeWidth = thickness.coerceAtLeast(0f) + isAntiAlias = true + }.use { paint -> + canvas.drawLine(start.x, start.y, end.x, end.y, paint) + } + } + + /** + * Draws a filled rectangle. + * + * Returns early when dimensions are invalid or no canvas is available. + * + * @param pos top-left position of the rectangle + * @param dim width and height of the rectangle + * @param color ARGB fill color + */ + @JvmStatic + fun drawRect(pos: Vec2f, dim: Dimensions, color: Int) { + if (!isValid(dim)) { + return + } + + val canvas = canvas ?: return + + Paint().setColor(color).use { paint -> + canvas.drawRect(Rect.makeXYWH(pos.x, pos.y, dim.width, dim.height), paint) + } + } + + /** + * Draws a rectangle filled with a linear gradient. + * + * Returns early when dimensions are invalid or no canvas is available. + * + * @param pos top-left position + * @param dim rectangle size + * @param colorStart starting ARGB color of the gradient + * @param colorEnd ending ARGB color of the gradient + * @param direction direction of the gradient flow + */ + @JvmStatic + fun drawGradientRect(pos: Vec2f, dim: Dimensions, colorStart: Int, colorEnd: Int, direction: SkiaGradient) { + if (!isValid(dim)) { + return + } + + val canvas = canvas ?: return + + createLinearGradientShader(pos, dim, colorStart, colorEnd, direction).use { shader -> + Paint().apply { + this.shader = shader + isAntiAlias = true + }.use { paint -> + canvas.drawRect( + Rect.makeXYWH(pos.x, pos.y, dim.width, dim.height), + paint + ) + } + } + } + + /** + * Draws a rectangle outline. + * + * Returns early when dimensions are invalid or no canvas is available. + * + * @param pos top-left position + * @param dim rectangle size + * @param color ARGB stroke color + * @param thickness stroke width in pixels (clamped to >= 0) + */ + @JvmStatic + fun drawOutline(pos: Vec2f, dim: Dimensions, color: Int, thickness: Float = 1f) { + if (!isValid(dim)) { + return + } + + val canvas = canvas ?: return + + val thickness = thickness.coerceAtLeast(0f) + val half = thickness / 2f + + Paint().apply { + setColor(color) + mode = PaintMode.STROKE + strokeWidth = thickness + isAntiAlias = true + }.use { paint -> + canvas.drawRect( + Rect.makeXYWH(pos.x + half, pos.y + half, dim.width - thickness, dim.height - thickness), + paint, + ) + } + } + + /** + * Draws a filled rounded rectangle. + * + * Returns early when dimensions are invalid or no canvas is available. + * + * @param pos top-left position + * @param dim rectangle size + * @param radius corner radius in pixels (clamped to >= 0) + * @param color ARGB fill color + */ + @JvmStatic + fun drawRoundedRect(pos: Vec2f, dim: Dimensions, radius: Float, color: Int) { + if (!isValid(dim)) { + return + } + + val canvas = canvas ?: return + + Paint().setColor(color).use { paint -> + canvas.drawRRect(RRect.makeXYWH(pos.x, pos.y, dim.width, dim.height, radius.coerceAtLeast(0f)), paint) + } + } + + /** + * Draws a rounded rectangle filled with a linear gradient. + * + * Returns early when dimensions are invalid or no canvas is available. + * + * @param pos top-left position + * @param dim rectangle size + * @param radius corner radius in pixels + * @param colorStart starting ARGB color of the gradient + * @param colorEnd ending ARGB color of the gradient + * @param direction direction of the gradient flow + */ + @JvmStatic + fun drawGradientRoundedRect( + pos: Vec2f, dim: Dimensions, + radius: Float, colorStart: Int, colorEnd: Int, direction: SkiaGradient, + ) { + if (!isValid(dim)) { + return + } + + val canvas = canvas ?: return + + createLinearGradientShader(pos, dim, colorStart, colorEnd, direction).use { shader -> + Paint().apply { + this.shader = shader + isAntiAlias = true + }.use { paint -> + canvas.drawRRect( + RRect.makeXYWH(pos.x, pos.y, dim.width, dim.height, radius.coerceAtLeast(0f)), + paint + ) + } + } + } + + /** + * Draws a rounded rectangle outline. + * + * Returns early when dimensions are invalid or no canvas is available. + * + * @param pos top-left position + * @param dim rectangle size + * @param radius outer corner radius in pixels + * @param color ARGB stroke color + * @param thickness outline width in pixels (clamped to >= 1) + */ + @JvmStatic + fun drawRoundedOutline( + pos: Vec2f, dim: Dimensions, + radius: Float, color: Int, thickness: Float = 1f, + ) { + if (!isValid(dim)) { + return + } + + val canvas = canvas ?: return + + val thickness = thickness.coerceAtLeast(1f) + val half = thickness / 2f + val innerRadius = (radius - half).coerceAtLeast(0f) + + Paint().apply { + setColor(color) + mode = PaintMode.STROKE + strokeWidth = thickness + isAntiAlias = true + }.use { paint -> + canvas.drawRRect( + RRect.makeXYWH(pos.x + half, pos.y + half, dim.width - thickness, dim.height - thickness, innerRadius), paint + ) + } + } + + /** + * Draws a rectangle with selectively rounded corners on one side. + * + * Returns early when dimensions are invalid or no canvas is available. + * + * @param pos top-left position + * @param dim rectangle size + * @param radius corner radius in pixels + * @param color ARGB fill color + * @param side which side(s) should be rounded + */ + @JvmStatic + fun drawHalfRoundedRect( + pos: Vec2f, dim: Dimensions, + radius: Float, color: Int, side: SkiaSide = SkiaSide.TOP, + ) { + if (!isValid(dim)) { + return + } + + val canvas = canvas ?: return + val radius = radius.coerceAtLeast(0f) + val radii = buildRadii(side, radius) + + Paint().apply { + setColor(color) + isAntiAlias = true + }.use { paint -> + canvas.drawRRect(RRect.makeComplexXYWH(pos.x, pos.y, dim.width, dim.height, radii), paint) + } + } + + private fun createLinearGradientShader( + pos: Vec2f, + dim: Dimensions, + colorStart: Int, + colorEnd: Int, + direction: SkiaGradient, + ): Shader { + val (x1, y1) = calculateGradientEnd(pos, dim, direction) + + return Shader.makeLinearGradient( + pos.x, pos.y, + x1, y1, + intArrayOf(colorStart, colorEnd) + ) + } + + private fun calculateGradientEnd( + pos: Vec2f, + dim: Dimensions, + direction: SkiaGradient, + ): Pair { + return when (direction) { + SkiaGradient.LEFT_TO_RIGHT -> pos.x + dim.width to pos.y + SkiaGradient.TOP_TO_BOTTOM -> pos.x to pos.y + dim.height + } + } + + private fun buildRadii(side: SkiaSide, radius: Float): FloatArray { + return when (side) { + SkiaSide.TOP -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) + SkiaSide.BOTTOM -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) + SkiaSide.LEFT -> floatArrayOf(radius, radius, 0f, 0f, 0f, 0f, radius, radius) + SkiaSide.RIGHT -> floatArrayOf(0f, 0f, radius, radius, radius, radius, 0f, 0f) + } + } + + private fun isValid(dim: Dimensions) = dim.width > 0f && dim.height > 0f + +} diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaText.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaText.kt new file mode 100644 index 00000000..b6b1d3df --- /dev/null +++ b/src/main/kotlin/org/cobalt/util/skia/SkiaText.kt @@ -0,0 +1,103 @@ +package org.cobalt.util.skia + +import io.github.humbleui.skija.Data +import io.github.humbleui.skija.Font +import io.github.humbleui.skija.FontEdging +import io.github.humbleui.skija.FontHinting +import io.github.humbleui.skija.FontMgr +import io.github.humbleui.skija.Paint +import io.github.humbleui.skija.TextLine +import java.io.IOException +import org.cobalt.math.Vec2f +import org.cobalt.util.skia.SkiaContext.canvas + +/** + * Utility for font loading and text rendering via Skia. + */ +object SkiaText { + + /** + * Primary UI font used throughout the client. + */ + @JvmField + val primaryFont: Font = loadFont("assets/cobalt/font/ProductSans-Bold.ttf") + + private val fonts = mutableMapOf() + + /** + * Loads a font from classpath resources and caches it by path. + * + * @param resourcePath classpath path to a font file + * @return loaded [Font] instance configured for UI rendering + * + * @throws IOException if the resource cannot be found + * @throws IllegalArgumentException if the font bytes are invalid + */ + @JvmStatic + fun loadFont(resourcePath: String) = fonts.computeIfAbsent(resourcePath) { + val bytes = javaClass.classLoader + ?.getResourceAsStream(resourcePath) + ?.use { it.readAllBytes() } + ?: throw IOException("Font resource not found: $resourcePath") + + val font = FontMgr.getDefault().makeFromData(Data.makeFromBytes(bytes)) + ?: throw IllegalArgumentException("Invalid font data: $resourcePath") + + Font(font).apply { + isSubpixel = false + hinting = FontHinting.NORMAL + edging = FontEdging.ANTI_ALIAS + } + } + + /** + * Draws text at the given position using the provided font and style. + * + * No-op when no canvas is available. + * + * @param font font instance to use + * @param text text content to render + * @param pos target position in screen space + * @param style text style containing font size and color + */ + @JvmStatic + fun drawText(font: Font, text: String, pos: Vec2f, style: TextStyle) { + val canvas = canvas ?: return + + font.size = style.fontSize + + TextLine.make(text, font).use { line -> + val baseline = pos.y - line.ascent - 1f + + Paint().setColor(style.color).use { paint -> + canvas.drawTextLine(line, pos.x, baseline, paint) + } + } + } + + /** + * Measures the rendered width of text for the given font size. + * + * @param font font instance to measure with + * @param text text content to measure + * @param fontSize font size used for measurement + * @return text width in pixels + */ + @JvmStatic + fun getTextWidth(font: Font, text: String, fontSize: Float): Float { + font.size = fontSize + + TextLine.make(text, font).use { line -> + return line.width + } + } + + /** + * Immutable text rendering style. + * + * @property fontSize font size in pixels + * @property color ARGB color value + */ + data class TextStyle(val fontSize: Float, val color: Int) + +} diff --git a/src/main/kotlin/org/cobalt/util/skia/SkiaTransforms.kt b/src/main/kotlin/org/cobalt/util/skia/SkiaTransforms.kt new file mode 100644 index 00000000..257d3ec9 --- /dev/null +++ b/src/main/kotlin/org/cobalt/util/skia/SkiaTransforms.kt @@ -0,0 +1,47 @@ +package org.cobalt.util.skia + +import org.cobalt.math.Vec2f +import org.cobalt.util.skia.SkiaContext.canvas + +/** + * Utility for applying transform and state operations to the current Skia canvas. + */ +object SkiaTransforms { + + /** + * Saves the current canvas state. + * + * No-op when no canvas is available. + */ + @JvmStatic + fun save() = canvas?.save() + + /** + * Restores the most recently saved canvas state. + * + * No-op when no canvas is available. + */ + @JvmStatic + fun restore() = canvas?.restore() + + /** + * Translates the canvas by the given position offset. + * + * No-op when no canvas is available. + * + * @param pos translation offset on X/Y axes + */ + @JvmStatic + fun translate(pos: Vec2f) = canvas?.translate(pos.x, pos.y) + + /** + * Scales the canvas by the given X/Y factors. + * + * No-op when no canvas is available. + * + * @param scale scale factors on X/Y axes + */ + @JvmStatic + fun scale(scale: Vec2f) = canvas?.scale(scale.x, scale.y) + +} diff --git a/src/main/kotlin/org/cobalt/util/skia/WrappedBackendRenderTarget.kt b/src/main/kotlin/org/cobalt/util/skia/WrappedBackendRenderTarget.kt index 3536bbd8..23acf15c 100644 --- a/src/main/kotlin/org/cobalt/util/skia/WrappedBackendRenderTarget.kt +++ b/src/main/kotlin/org/cobalt/util/skia/WrappedBackendRenderTarget.kt @@ -19,9 +19,22 @@ package org.cobalt.util.skia import io.github.humbleui.skija.BackendRenderTarget +import io.github.humbleui.skija.BackendRenderTarget._nMakeGL import io.github.humbleui.skija.impl.Stats import org.jetbrains.annotations.Contract +/** + * [BackendRenderTarget] wrapper that stores the OpenGL framebuffer metadata + * used to create and track the native render-target pointer. + * + * @property width framebuffer width in pixels + * @property height framebuffer height in pixels + * @property sampleCnt number of samples used for multisampling + * @property stencilBits number of stencil bits in the framebuffer + * @property fbId OpenGL framebuffer object id + * @property fbFormat OpenGL framebuffer format enum value + * @param ptr native backend render-target pointer owned by [BackendRenderTarget] + */ class WrappedBackendRenderTarget( val width: Int, val height: Int, @@ -29,14 +42,29 @@ class WrappedBackendRenderTarget( val stencilBits: Int, val fbId: Int, val fbFormat: Int, - ptr: Long + ptr: Long, ) : BackendRenderTarget(ptr) { companion object { + /** + * Create a new [WrappedBackendRenderTarget] backed by an OpenGL framebuffer. + * + * The native helper [_nMakeGL] allocates the underlying backend render target. + * The resulting pointer is then passed to [BackendRenderTarget] through the + * [WrappedBackendRenderTarget] constructor. + * + * @param width framebuffer width in pixels + * @param height framebuffer height in pixels + * @param sampleCnt number of samples for multisampling + * @param stencilBits number of stencil bits in the framebuffer + * @param fbId OpenGL framebuffer id + * @param fbFormat OpenGL framebuffer format + * @return a new [WrappedBackendRenderTarget] instance with native pointer state + */ @Contract("_, _, _, _, _, _ -> new") - fun makeGL( - width: Int, height: Int, sampleCnt: Int, stencilBits: Int, fbId: Int, fbFormat: Int + internal fun makeGL( + width: Int, height: Int, sampleCnt: Int, stencilBits: Int, fbId: Int, fbFormat: Int, ): WrappedBackendRenderTarget { Stats.onNativeCall() return WrappedBackendRenderTarget( diff --git a/src/main/kotlin/org/cobalt/util/skia/gl/Properties.kt b/src/main/kotlin/org/cobalt/util/skia/gl/Properties.kt index 30612cca..e5e4b49f 100644 --- a/src/main/kotlin/org/cobalt/util/skia/gl/Properties.kt +++ b/src/main/kotlin/org/cobalt/util/skia/gl/Properties.kt @@ -1,25 +1,11 @@ -/* - * This file is part of https://github.com/Lyzev/Skija. - * - * Copyright (c) 2025. Lyzev - * - * Skija is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Skija is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Skija. If not, see . - */ - package org.cobalt.util.skia.gl import java.util.* +/** + * Represents the OpenGL state. + */ +@Suppress("UndocumentedPublicProperty") class Properties { val lastActiveTexture = IntArray(1) diff --git a/src/main/kotlin/org/cobalt/util/skia/gl/State.kt b/src/main/kotlin/org/cobalt/util/skia/gl/State.kt index db586c85..0d1dd352 100644 --- a/src/main/kotlin/org/cobalt/util/skia/gl/State.kt +++ b/src/main/kotlin/org/cobalt/util/skia/gl/State.kt @@ -1,3 +1,5 @@ +@file:Suppress("WildcardImport") // Imports 60+ items, so a wildcard import is fine here. + /* * This file is part of https://github.com/Lyzev/Skija. * @@ -15,16 +17,48 @@ * * You should have received a copy of the GNU General Public License along with Skija. If not, see . */ - package org.cobalt.util.skia.gl import org.lwjgl.opengl.GL import org.lwjgl.opengl.GL45.* +private const val GL_VERSION_3_3 = 330 +private const val GL_VERSION_3_1 = 310 +private const val GL_VERSION_2_0 = 200 +private const val GL_VERSION_1_2 = 120 + +private const val DEFAULT_PIXEL_UNPACK_BINDING = 0 +private const val DEFAULT_SAMPLER_UNIT = 0 + +private const val DEFAULT_UNPACK_ALIGNMENT = 1 +private const val DEFAULT_UNPACK_ROW_LENGTH = 0 +private const val DEFAULT_UNPACK_SKIP_PIXELS = 0 +private const val DEFAULT_UNPACK_SKIP_ROWS = 0 +private const val VIEWPORT_X = 0 +private const val VIEWPORT_Y = 1 +private const val VIEWPORT_W = 2 +private const val VIEWPORT_H = 3 + +private const val SCISSOR_X = 0 +private const val SCISSOR_Y = 1 +private const val SCISSOR_W = 2 +private const val SCISSOR_H = 3 + +/** + * Represents the OpenGL state. + * + * @property glVersion The current OpenGL version. + */ class State(private val glVersion: Int) { private val props = Properties() + /** + * Saves the current OpenGL state. + * + * @return this [State] instance for convenience + * @see pop + */ fun push(): State { with(props) { glGetIntegerv(GL_ACTIVE_TEXTURE, lastActiveTexture) @@ -32,84 +66,127 @@ class State(private val glVersion: Int) { glGetIntegerv(GL_CURRENT_PROGRAM, lastProgram) glGetIntegerv(GL_TEXTURE_BINDING_2D, lastTexture) - if (glVersion >= 330 || GL.getCapabilities().GL_ARB_sampler_objects) { - glGetIntegerv(GL_SAMPLER_BINDING, lastSampler) - } + pushBindings() + pushEnables() + pushPixelStores() - glGetIntegerv(GL_ARRAY_BUFFER_BINDING, lastArrayBuffer) - glGetIntegerv(GL_VERTEX_ARRAY_BINDING, lastVertexArrayObject) + glPixelStorei(GL_UNPACK_ALIGNMENT, DEFAULT_UNPACK_ALIGNMENT) + glPixelStorei(GL_UNPACK_ROW_LENGTH, DEFAULT_UNPACK_ROW_LENGTH) + glPixelStorei(GL_UNPACK_SKIP_PIXELS, DEFAULT_UNPACK_SKIP_PIXELS) + glPixelStorei(GL_UNPACK_SKIP_ROWS, DEFAULT_UNPACK_SKIP_ROWS) + } - if (glVersion >= 200) { - glGetIntegerv(GL_POLYGON_MODE, lastPolygonMode) - } + return this + } - glGetIntegerv(GL_VIEWPORT, lastViewport) - glGetIntegerv(GL_SCISSOR_BOX, lastScissorBox) - glGetIntegerv(GL_BLEND_SRC_RGB, lastBlendSrcRgb) - glGetIntegerv(GL_BLEND_DST_RGB, lastBlendDstRgb) - glGetIntegerv(GL_BLEND_SRC_ALPHA, lastBlendSrcAlpha) - glGetIntegerv(GL_BLEND_DST_ALPHA, lastBlendDstAlpha) - glGetIntegerv(GL_BLEND_EQUATION_RGB, lastBlendEquationRgb) - glGetIntegerv(GL_BLEND_EQUATION_ALPHA, lastBlendEquationAlpha) - - lastEnableBlend = glIsEnabled(GL_BLEND) - lastEnableCullFace = glIsEnabled(GL_CULL_FACE) - lastEnableDepthTest = glIsEnabled(GL_DEPTH_TEST) - lastEnableStencilTest = glIsEnabled(GL_STENCIL_TEST) - lastEnableScissorTest = glIsEnabled(GL_SCISSOR_TEST) - - if (glVersion >= 310) { - lastEnablePrimitiveRestart = glIsEnabled(GL_PRIMITIVE_RESTART) - } + private fun Properties.pushBindings() { + if (glVersion >= GL_VERSION_3_3 || GL.getCapabilities().GL_ARB_sampler_objects) { + glGetIntegerv(GL_SAMPLER_BINDING, lastSampler) + } - lastDepthMask = glGetBoolean(GL_DEPTH_WRITEMASK) - - glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, lastPixelUnpackBufferBinding) - glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0) - - glGetIntegerv(GL_PACK_SWAP_BYTES, lastPackSwapBytes) - glGetIntegerv(GL_PACK_LSB_FIRST, lastPackLsbFirst) - glGetIntegerv(GL_PACK_ROW_LENGTH, lastPackRowLength) - glGetIntegerv(GL_PACK_SKIP_PIXELS, lastPackSkipPixels) - glGetIntegerv(GL_PACK_SKIP_ROWS, lastPackSkipRows) - glGetIntegerv(GL_PACK_ALIGNMENT, lastPackAlignment) - - glGetIntegerv(GL_UNPACK_SWAP_BYTES, lastUnpackSwapBytes) - glGetIntegerv(GL_UNPACK_LSB_FIRST, lastUnpackLsbFirst) - glGetIntegerv(GL_UNPACK_ALIGNMENT, lastUnpackAlignment) - glGetIntegerv(GL_UNPACK_ROW_LENGTH, lastUnpackRowLength) - glGetIntegerv(GL_UNPACK_SKIP_PIXELS, lastUnpackSkipPixels) - glGetIntegerv(GL_UNPACK_SKIP_ROWS, lastUnpackSkipRows) - - if (glVersion >= 120) { - glGetIntegerv(GL_PACK_IMAGE_HEIGHT, lastPackImageHeight) - glGetIntegerv(GL_PACK_SKIP_IMAGES, lastPackSkipImages) - glGetIntegerv(GL_UNPACK_IMAGE_HEIGHT, lastUnpackImageHeight) - glGetIntegerv(GL_UNPACK_SKIP_IMAGES, lastUnpackSkipImages) - } + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, lastArrayBuffer) + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, lastVertexArrayObject) - glPixelStorei(GL_UNPACK_ALIGNMENT, 1) - glPixelStorei(GL_UNPACK_ROW_LENGTH, 0) - glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0) - glPixelStorei(GL_UNPACK_SKIP_ROWS, 0) + if (glVersion >= GL_VERSION_2_0) { + glGetIntegerv(GL_POLYGON_MODE, lastPolygonMode) } - return this + glGetIntegerv(GL_VIEWPORT, lastViewport) + glGetIntegerv(GL_SCISSOR_BOX, lastScissorBox) + glGetIntegerv(GL_BLEND_SRC_RGB, lastBlendSrcRgb) + glGetIntegerv(GL_BLEND_DST_RGB, lastBlendDstRgb) + glGetIntegerv(GL_BLEND_SRC_ALPHA, lastBlendSrcAlpha) + glGetIntegerv(GL_BLEND_DST_ALPHA, lastBlendDstAlpha) + glGetIntegerv(GL_BLEND_EQUATION_RGB, lastBlendEquationRgb) + glGetIntegerv(GL_BLEND_EQUATION_ALPHA, lastBlendEquationAlpha) + } + + private fun Properties.pushEnables() { + lastEnableBlend = glIsEnabled(GL_BLEND) + lastEnableCullFace = glIsEnabled(GL_CULL_FACE) + lastEnableDepthTest = glIsEnabled(GL_DEPTH_TEST) + lastEnableStencilTest = glIsEnabled(GL_STENCIL_TEST) + lastEnableScissorTest = glIsEnabled(GL_SCISSOR_TEST) + + if (glVersion >= GL_VERSION_3_1) { + lastEnablePrimitiveRestart = glIsEnabled(GL_PRIMITIVE_RESTART) + } + + lastDepthMask = glGetBoolean(GL_DEPTH_WRITEMASK) + } + + private fun Properties.pushPixelStores() { + glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, lastPixelUnpackBufferBinding) + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, DEFAULT_PIXEL_UNPACK_BINDING) + + pushPackPixelStores() + pushUnpackPixelStores() + + if (glVersion >= GL_VERSION_1_2) { + glGetIntegerv(GL_PACK_IMAGE_HEIGHT, lastPackImageHeight) + glGetIntegerv(GL_PACK_SKIP_IMAGES, lastPackSkipImages) + glGetIntegerv(GL_UNPACK_IMAGE_HEIGHT, lastUnpackImageHeight) + glGetIntegerv(GL_UNPACK_SKIP_IMAGES, lastUnpackSkipImages) + } + } + + private fun Properties.pushPackPixelStores() { + glGetIntegerv(GL_PACK_SWAP_BYTES, lastPackSwapBytes) + glGetIntegerv(GL_PACK_LSB_FIRST, lastPackLsbFirst) + glGetIntegerv(GL_PACK_ROW_LENGTH, lastPackRowLength) + glGetIntegerv(GL_PACK_SKIP_PIXELS, lastPackSkipPixels) + glGetIntegerv(GL_PACK_SKIP_ROWS, lastPackSkipRows) + glGetIntegerv(GL_PACK_ALIGNMENT, lastPackAlignment) + } + + private fun Properties.pushUnpackPixelStores() { + glGetIntegerv(GL_UNPACK_SWAP_BYTES, lastUnpackSwapBytes) + glGetIntegerv(GL_UNPACK_LSB_FIRST, lastUnpackLsbFirst) + glGetIntegerv(GL_UNPACK_ALIGNMENT, lastUnpackAlignment) + glGetIntegerv(GL_UNPACK_ROW_LENGTH, lastUnpackRowLength) + glGetIntegerv(GL_UNPACK_SKIP_PIXELS, lastUnpackSkipPixels) + glGetIntegerv(GL_UNPACK_SKIP_ROWS, lastUnpackSkipRows) } + /** + * Restores the state that was saved with [push]. + * + * @return this [State] instance after restoration. + * @see push + */ fun pop(): State { + restoreProgramAndTexture() + restoreSamplersAndBindings() + restoreBlendAndCapabilities() + restorePolygonViewportAndScissor() + restorePixelStoresAndBuffers() + restoreDepthMask() + return this + } + + private fun restoreProgramAndTexture() { with(props) { glUseProgram(lastProgram[0]) glBindTexture(GL_TEXTURE_2D, lastTexture[0]) + } + } - if (glVersion >= 330 || GL.getCapabilities().GL_ARB_sampler_objects) { - glBindSampler(0, lastSampler[0]) + private fun restoreSamplersAndBindings() { + with(props) { + if (glVersion >= GL_VERSION_3_3 || GL.getCapabilities().GL_ARB_sampler_objects) { + glBindSampler(DEFAULT_SAMPLER_UNIT, lastSampler[0]) } glActiveTexture(lastActiveTexture[0]) glBindVertexArray(lastVertexArrayObject[0]) glBindBuffer(GL_ARRAY_BUFFER, lastArrayBuffer[0]) + } + } + + private fun restoreBlendAndCapabilities() { + with(props) { glBlendEquationSeparate(lastBlendEquationRgb[0], lastBlendEquationAlpha[0]) + glBlendFuncSeparate( lastBlendSrcRgb[0], lastBlendDstRgb[0], @@ -117,60 +194,81 @@ class State(private val glVersion: Int) { lastBlendDstAlpha[0] ) - if (lastEnableBlend) glEnable(GL_BLEND) - else glDisable(GL_BLEND) - if (lastEnableCullFace) glEnable(GL_CULL_FACE) - else glDisable(GL_CULL_FACE) - if (lastEnableDepthTest) glEnable(GL_DEPTH_TEST) - else glDisable(GL_DEPTH_TEST) - if (lastEnableStencilTest) glEnable(GL_STENCIL_TEST) - else glDisable(GL_STENCIL_TEST) - if (lastEnableScissorTest) glEnable(GL_SCISSOR_TEST) - else glDisable(GL_SCISSOR_TEST) - - if (glVersion >= 310) { - if (lastEnablePrimitiveRestart) glEnable(GL_PRIMITIVE_RESTART) - else glDisable(GL_PRIMITIVE_RESTART) + setEnable(GL_BLEND, lastEnableBlend) + setEnable(GL_CULL_FACE, lastEnableCullFace) + setEnable(GL_DEPTH_TEST, lastEnableDepthTest) + setEnable(GL_STENCIL_TEST, lastEnableStencilTest) + setEnable(GL_SCISSOR_TEST, lastEnableScissorTest) + + if (glVersion >= GL_VERSION_3_1) { + setEnable(GL_PRIMITIVE_RESTART, lastEnablePrimitiveRestart) } + } + } + + private fun setEnable(capability: Int, enable: Boolean) { + if (enable) glEnable(capability) else glDisable(capability) + } - if (glVersion >= 200) { + private fun restorePolygonViewportAndScissor() { + with(props) { + if (glVersion >= GL_VERSION_2_0) { glPolygonMode(GL_FRONT_AND_BACK, lastPolygonMode[0]) } - glViewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3]) + glViewport( + lastViewport[VIEWPORT_X], + lastViewport[VIEWPORT_Y], + lastViewport[VIEWPORT_W], + lastViewport[VIEWPORT_H] + ) + glScissor( - lastScissorBox[0], - lastScissorBox[1], - lastScissorBox[2], - lastScissorBox[3] + lastScissorBox[SCISSOR_X], + lastScissorBox[SCISSOR_Y], + lastScissorBox[SCISSOR_W], + lastScissorBox[SCISSOR_H] ) + } + } - glPixelStorei(GL_PACK_SWAP_BYTES, lastPackSwapBytes[0]) - glPixelStorei(GL_PACK_LSB_FIRST, lastPackLsbFirst[0]) - glPixelStorei(GL_PACK_ROW_LENGTH, lastPackRowLength[0]) - glPixelStorei(GL_PACK_SKIP_PIXELS, lastPackSkipPixels[0]) - glPixelStorei(GL_PACK_SKIP_ROWS, lastPackSkipRows[0]) - glPixelStorei(GL_PACK_ALIGNMENT, lastPackAlignment[0]) - - glBindBuffer(GL_PIXEL_UNPACK_BUFFER, lastPixelUnpackBufferBinding[0]) - glPixelStorei(GL_UNPACK_SWAP_BYTES, lastUnpackSwapBytes[0]) - glPixelStorei(GL_UNPACK_LSB_FIRST, lastUnpackLsbFirst[0]) - glPixelStorei(GL_UNPACK_ALIGNMENT, lastUnpackAlignment[0]) - glPixelStorei(GL_UNPACK_ROW_LENGTH, lastUnpackRowLength[0]) - glPixelStorei(GL_UNPACK_SKIP_PIXELS, lastUnpackSkipPixels[0]) - glPixelStorei(GL_UNPACK_SKIP_ROWS, lastUnpackSkipRows[0]) - - if (glVersion >= 120) { + private fun restorePixelStoresAndBuffers() { + with(props) { + restorePackPixelStores() + restoreUnpackPixelStores() + + if (glVersion >= GL_VERSION_1_2) { glPixelStorei(GL_PACK_IMAGE_HEIGHT, lastPackImageHeight[0]) glPixelStorei(GL_PACK_SKIP_IMAGES, lastPackSkipImages[0]) glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, lastUnpackImageHeight[0]) glPixelStorei(GL_UNPACK_SKIP_IMAGES, lastUnpackSkipImages[0]) } + } + } + private fun Properties.restorePackPixelStores() { + glPixelStorei(GL_PACK_SWAP_BYTES, lastPackSwapBytes[0]) + glPixelStorei(GL_PACK_LSB_FIRST, lastPackLsbFirst[0]) + glPixelStorei(GL_PACK_ROW_LENGTH, lastPackRowLength[0]) + glPixelStorei(GL_PACK_SKIP_PIXELS, lastPackSkipPixels[0]) + glPixelStorei(GL_PACK_SKIP_ROWS, lastPackSkipRows[0]) + glPixelStorei(GL_PACK_ALIGNMENT, lastPackAlignment[0]) + } + + private fun Properties.restoreUnpackPixelStores() { + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, lastPixelUnpackBufferBinding[0]) + glPixelStorei(GL_UNPACK_SWAP_BYTES, lastUnpackSwapBytes[0]) + glPixelStorei(GL_UNPACK_LSB_FIRST, lastUnpackLsbFirst[0]) + glPixelStorei(GL_UNPACK_ALIGNMENT, lastUnpackAlignment[0]) + glPixelStorei(GL_UNPACK_ROW_LENGTH, lastUnpackRowLength[0]) + glPixelStorei(GL_UNPACK_SKIP_PIXELS, lastUnpackSkipPixels[0]) + glPixelStorei(GL_UNPACK_SKIP_ROWS, lastUnpackSkipRows[0]) + } + + private fun restoreDepthMask() { + with(props) { glDepthMask(lastDepthMask) } - - return this } } diff --git a/src/main/kotlin/org/cobalt/util/skia/gl/States.kt b/src/main/kotlin/org/cobalt/util/skia/gl/States.kt index 74e4e821..06277560 100644 --- a/src/main/kotlin/org/cobalt/util/skia/gl/States.kt +++ b/src/main/kotlin/org/cobalt/util/skia/gl/States.kt @@ -18,18 +18,35 @@ package org.cobalt.util.skia.gl -import org.lwjgl.opengl.GL30.* -import java.util.* +import java.util.Stack +import org.lwjgl.opengl.GL30.GL_MAJOR_VERSION +import org.lwjgl.opengl.GL30.GL_MINOR_VERSION +import org.lwjgl.opengl.GL30.glGetIntegerv +private const val GL_MAJOR_MULTIPLIER = 100 +private const val GL_MINOR_MULTIPLIER = 10 + +/** + * Stores and restores OpenGL states. + */ object States { private val glVersion: Int private val states = Stack() + /** + * Pushes the current OpenGL state onto the stack. + */ fun push() { states += State(glVersion).push() } + /** + * Pops the last OpenGL state from the stack and restores it. + * + * Throws an [IllegalArgumentException] if there is no saved state to + * restore. + */ fun pop() { require(states.isNotEmpty()) { "No state to restore." } states.pop().pop() @@ -40,7 +57,7 @@ object States { val minor = IntArray(1) glGetIntegerv(GL_MAJOR_VERSION, major) glGetIntegerv(GL_MINOR_VERSION, minor) - glVersion = major[0] * 100 + minor[0] * 10 + glVersion = major[0] * GL_MAJOR_MULTIPLIER + minor[0] * GL_MINOR_MULTIPLIER } } diff --git a/src/main/resources/cobalt.mixins.json b/src/main/resources/cobalt.mixins.json index 281bf5f8..aa922ea1 100644 --- a/src/main/resources/cobalt.mixins.json +++ b/src/main/resources/cobalt.mixins.json @@ -6,7 +6,6 @@ "client": [ "client.AbstractClientPlayerAccessor", "client.MinecraftMixin", - "gui.ChatScreenMixin", "gui.ClientPacketListenerMixin", "gui.CommandSuggestionsMixin", "gui.GuiRendererMixin",