diff --git a/plugin-playground/src/main/kotlin/XPGlobesPlugin/Constants.kt b/plugin-playground/src/main/kotlin/XPGlobesPlugin/Constants.kt index 2a05b7c..6990c89 100644 --- a/plugin-playground/src/main/kotlin/XPGlobesPlugin/Constants.kt +++ b/plugin-playground/src/main/kotlin/XPGlobesPlugin/Constants.kt @@ -8,7 +8,7 @@ object Constants { const val MAX_LEVEL = 99 const val INVALID_LEVEL = -1 const val INVALID_XP = -1 - const val GLOBE_LIFETIME = 5000L // 5 seconds + const val GLOBE_LIFETIME = 7000L // 7 seconds const val GLOBES_Y_OFFSET = 48 // y-offset in screen space where globes are drawn val GLOBE_BKG_COLOR: Color = Color.GRAY const val GLOBE_BKG_SIZE = 33 // Size of the globe background @@ -20,4 +20,11 @@ object Constants { const val MAX_GLOBES = 6 // maximum number of globes we will draw on resizable clients const val MAX_GLOBES_SD = 3 // maximum number of globes we will draw on fixed clients const val GLOBE_PADDING = 3 // horizontal padding between globes + const val GLOBE_TEXT_SIZE = 20 // font size of level text + const val GLOBE_TEXT_PULSES = 7 // 7 text pulses per globe lifetime, on level-up + const val GLOBE_TEXT_PULSE_LOW = 0.2F // alpha during text fade-out + const val GLOBE_TEXT_PULSE_HIGH = 0.8F // alpha during text fade-in + val GLOBE_TEXT_FG_COLOR: Color = Color.BLUE // foreground color of level text during level-up + val GLOBE_TEXT_BG_COLOR: Color = Color.BLACK // background color (outline) of text number during level-up + const val GLOBE_TEXT_OUTLINE_WIDTH = 2 // outline width } diff --git a/plugin-playground/src/main/kotlin/XPGlobesPlugin/plugin.kt b/plugin-playground/src/main/kotlin/XPGlobesPlugin/plugin.kt index 144f2a0..5ef5251 100644 --- a/plugin-playground/src/main/kotlin/XPGlobesPlugin/plugin.kt +++ b/plugin-playground/src/main/kotlin/XPGlobesPlugin/plugin.kt @@ -8,6 +8,13 @@ import plugin.api.* import java.awt.Color import java.awt.geom.Arc2D import java.awt.image.BufferedImage +import java.awt.Font +import java.awt.font.FontRenderContext +import java.awt.geom.AffineTransform +import java.awt.Graphics2D +import java.awt.BasicStroke +import kotlin.math.cos +import kotlin.math.PI @PluginMeta( @@ -18,14 +25,14 @@ import java.awt.image.BufferedImage class plugin : Plugin() { - private var xpGlobes = Array(Constants.SKILL_COUNT) { skillId -> XPGlobe(skillId, Constants.INVALID_XP, 0L, null) } - private var lastGain = 0L + private var xpGlobes = Array(Constants.SKILL_COUNT) { skillId -> XPGlobe(skillId, Constants.INVALID_XP, 0L, null, null ) } + private var hasActiveGlobes = false private var backgroundSprite: Sprite? = null private var borderSprite: Sprite? = null override fun Draw(deltaTime: Long) { - if (System.currentTimeMillis() - lastGain >= Constants.GLOBE_LIFETIME) + if (!hasActiveGlobes) return var posX = API.GetWindowDimensions().width / 2 @@ -43,6 +50,7 @@ class plugin : Plugin() { val globeDelta = System.currentTimeMillis() - xpGlobe.timestamp if (globeDelta >= Constants.GLOBE_LIFETIME) { xpGlobe.timestamp = 0L // dead + xpGlobe.textSprite = null } if (xpGlobe.timestamp != 0L) { @@ -50,6 +58,11 @@ class plugin : Plugin() { } } + if (activeGlobes.isEmpty()) { + hasActiveGlobes = false + return + } + val maxGlobes = if (API.GetWindowMode() == WindowMode.FIXED) Constants.MAX_GLOBES_SD else Constants.MAX_GLOBES val globeCount = if (activeGlobes.size > maxGlobes) maxGlobes else activeGlobes.size val (backgroundSize, globeBorder, xpBorder) = getGlobeDimensions() @@ -66,18 +79,19 @@ class plugin : Plugin() { override fun OnXPUpdate(skillId: Int, xp: Int) { - if (xpGlobes[skillId].xp == Constants.INVALID_XP) { - xpGlobes[skillId].xp = xp + val xpGlobe = xpGlobes[skillId] + if (xpGlobe.xp == Constants.INVALID_XP) { + xpGlobe.xp = xp return } - if (xp == xpGlobes[skillId].xp) { + if (xp == xpGlobe.xp) { return } - val prevXp = xpGlobes[skillId].xp - xpGlobes[skillId].xp = xp - xpGlobes[skillId].timestamp = 0L + val prevXp = xpGlobe.xp + xpGlobe.xp = xp + xpGlobe.timestamp = 0L val (prevLevel, _) = XPTable.getLevelForXp(prevXp) val (level, gainedXp) = XPTable.getLevelForXp(xp) @@ -85,20 +99,24 @@ class plugin : Plugin() { if (level != Constants.INVALID_LEVEL && level < Constants.MAX_LEVEL) { var arcWeight = 1.0 var arcColor = Constants.GLOBE_XP_ARC_LEVEL_UP_COLOR + var hasLeveledUp = level != prevLevel - if (level == prevLevel) { + if (!hasLeveledUp) { val levelXpDiff = XPTable.getXpRequiredForLevel(level + 1) - XPTable.getXpRequiredForLevel(level) arcWeight = gainedXp.toDouble() / levelXpDiff.toDouble() arcColor = Constants.GLOBE_XP_ARC_COLOR } - // remember timestamps - lastGain = System.currentTimeMillis() - xpGlobes[skillId].timestamp = lastGain + // remember timestamp + xpGlobe.timestamp = System.currentTimeMillis() + hasActiveGlobes = true val (backgroundSize, globeBorder, xpBorder) = getGlobeDimensions() val globeSize = backgroundSize + globeBorder * 2 + xpBorder * 2 - xpGlobes[skillId].arcSprite = createArcSprite(backgroundSize + xpBorder * 2, arcColor, arcWeight) + if (xpGlobe.textSprite == null) { + // update the arcSprite only if the textSprite has finished the animation + xpGlobe.arcSprite = createArcSprite(backgroundSize + xpBorder * 2, arcColor, arcWeight) + } if (borderSprite == null) { borderSprite = createArcSprite(globeSize, Constants.GLOBE_BORDER_COLOR, 1.0) @@ -106,17 +124,25 @@ class plugin : Plugin() { if (backgroundSprite == null) { backgroundSprite = createArcSprite(backgroundSize, Constants.GLOBE_BKG_COLOR, 1.0) } + + if (hasLeveledUp) { + val fontSize = Constants.GLOBE_TEXT_SIZE + val fgColor = Constants.GLOBE_TEXT_FG_COLOR + val bgColor = Constants.GLOBE_TEXT_BG_COLOR + val outlineSize = Constants.GLOBE_TEXT_OUTLINE_WIDTH + xpGlobes[skillId].textSprite = createTextSprite(fontSize, fgColor, bgColor, outlineSize, level.toString()) + } } } override fun OnLogout() { - lastGain = 0L - xpGlobes = Array(Constants.SKILL_COUNT) { skillId -> XPGlobe(skillId, Constants.INVALID_XP, 0L, null) } + hasActiveGlobes = false + xpGlobes = Array(Constants.SKILL_COUNT) { skillId -> XPGlobe(skillId, Constants.INVALID_XP, 0L, null, null) } } - data class XPGlobe(val skillId: Int, var xp: Int, var timestamp: Long, var arcSprite: Sprite?) + data class XPGlobe(val skillId: Int, var xp: Int, var timestamp: Long, var arcSprite: Sprite?, var textSprite: Sprite?) private fun getGlobeDimensions() : Triple { @@ -153,6 +179,88 @@ class plugin : Plugin() { val (spriteXOffset, spriteYOffset) = XPSprites.getSpriteOffsetForSkill(globe.skillId) skillSprite?.render(drawX + spriteXOffset, drawY + spriteYOffset) + + // rendering level-up text animation + if (globe.textSprite != null) { + val clamp: (Float, Float, Float) -> Float = { value, min, max -> + when { + value < min -> min + value > max -> max + else -> value + } + } + + val lerp: (Float, Float, Float) -> Float = { valA, valB, factor -> + valA * (1.0F - factor) + (valB * factor) + } + + val currentTime = System.currentTimeMillis() + var animWeight = (currentTime - globe.timestamp).toFloat() / Constants.GLOBE_LIFETIME + animWeight = clamp(animWeight, 0.0F, 1.0F) + // sample cosine for the text pulse animation effect (cosine offset is to respect number of pulses) + var pulseWeight = cos(animWeight * (PI + PI * 2 * Constants.GLOBE_TEXT_PULSES).toFloat()) + pulseWeight = (pulseWeight + 1.0F) / 2.0F // map pulseWeight from [-1,1] to [0,1] + val pulseIntensity = lerp(Constants.GLOBE_TEXT_PULSE_LOW, Constants.GLOBE_TEXT_PULSE_HIGH, pulseWeight) + + val globeSize = backgroundSize + globeBorder * 2 + xpBorder * 2 + + val textWidth = globe.textSprite?.anInt1860 ?: 0 + val textHeight = globe.textSprite?.anInt1866 ?: 0 + val textXOffset = (globeSize - textWidth) / 2 + val textYOffset = (globeSize - textHeight) / 2 + + val textX = posX + textXOffset + (Constants.GLOBE_TEXT_OUTLINE_WIDTH / 2) + val textY = posY + textYOffset + (Constants.GLOBE_TEXT_OUTLINE_WIDTH / 2) + + val alpha = lerp(0f, 255f, pulseIntensity).toInt() + globe.textSprite?.renderAlpha(textX, textY, alpha) + } + } + + private fun createTextSprite(size: Int, fgColor: Color, bgColor: Color, outlineSize: Int, text: String) : Sprite { + return SpritePNGLoader.getImageIndexedSprite(createTextImage(size, fgColor, bgColor, outlineSize, text)) + } + + private fun createTextImage(fontSize: Int, fgColor: Color, bgColor: Color, outlineSize: Int, text: String) : BufferedImage { + // Create a dummy font to calculate the size of the BufferedImage + val font = Font("Arial", Font.PLAIN, fontSize) + val affineTransform = AffineTransform() + val frc = FontRenderContext(affineTransform, true, true) + val textWidth = font.getStringBounds(text, frc).width.toInt() + outlineSize + val textHeight = font.getStringBounds(text, frc).height.toInt() + outlineSize + + // Create an image that can contain the text + val image = BufferedImage(textWidth, textHeight, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() as Graphics2D + + // Enable antialiasing for smoother text + graphics.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON) + + // Set the font + graphics.font = font + + // Calculate position for the text + val metrics = graphics.getFontMetrics(font) + val x = 0 + val y = metrics.ascent + + // Create a glyph vector for the outline + val glyphVector = font.createGlyphVector(frc, text) + val shape = glyphVector.getOutline(x.toFloat(), y.toFloat()) + + // Draw the outline (background color) + graphics.color = bgColor + graphics.stroke = BasicStroke(outlineSize.toFloat()) // Set the outline width + graphics.draw(shape) + + // Fill the text (foreground color) + graphics.color = fgColor + graphics.fill(shape) + + // Dispose graphics to release resources + graphics.dispose() + + return image }